From a2368f99a476940e81b6fad32fe00816898fa812 Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Tue, 12 May 2026 17:36:36 -0700 Subject: [PATCH 1/7] autoconfig remake apiv2 --- js/module.d.ts | 24 + js/module.ts | 47 + .../source/nodeobs_autoconfig.cpp | 56 +- .../source/nodeobs_autoconfig.hpp | 5 + obs-studio-client/source/streaming.cpp | 41 + obs-studio-client/source/streaming.hpp | 2 + obs-studio-client/source/video.cpp | 25 +- obs-studio-client/source/video.hpp | 10 +- obs-studio-server/CMakeLists.txt | 2 + obs-studio-server/source/nodeobs_api.cpp | 1 + obs-studio-server/source/nodeobs_api.h | 2 +- .../source/nodeobs_autoconfig.cpp | 1670 ++++++++--- obs-studio-server/source/nodeobs_autoconfig.h | 6 +- .../nodeobs_autoconfig_resource_sampler.cpp | 212 ++ .../nodeobs_autoconfig_resource_sampler.h | 112 + .../source/osn-advanced-replay-buffer.cpp | 1 + .../source/osn-advanced-streaming.cpp | 40 +- .../source/osn-advanced-streaming.hpp | 2 + .../source/osn-simple-recording.cpp | 3 +- .../source/osn-simple-replay-buffer.cpp | 21 +- .../source/osn-simple-streaming.cpp | 87 +- .../source/osn-simple-streaming.hpp | 4 +- .../source/osn-streaming-helpers.hpp | 105 + obs-studio-server/source/osn-streaming.cpp | 108 + obs-studio-server/source/osn-streaming.hpp | 20 + obs-studio-server/source/osn-video.cpp | 8 +- .../source/util-crashmanager.cpp | 1 + package.json | 2 + .../osn-tests/src/test_nodeobs_autoconfig.ts | 26 +- tests/osn-tests/src/test_osn_autoconfig_v2.ts | 433 +++ tests/osn-tests/util/mock_rtmp.ts | 91 + tests/osn-tests/util/obs_handler.ts | 30 +- yarn.lock | 2545 ++++++++++------- 33 files changed, 4153 insertions(+), 1589 deletions(-) create mode 100644 obs-studio-server/source/nodeobs_autoconfig_resource_sampler.cpp create mode 100644 obs-studio-server/source/nodeobs_autoconfig_resource_sampler.h create mode 100644 obs-studio-server/source/osn-streaming-helpers.hpp create mode 100644 tests/osn-tests/src/test_osn_autoconfig_v2.ts create mode 100644 tests/osn-tests/util/mock_rtmp.ts diff --git a/js/module.d.ts b/js/module.d.ts index 4a74523a5..831f98756 100644 --- a/js/module.d.ts +++ b/js/module.d.ts @@ -663,6 +663,7 @@ export interface IVideo { destroy(): void; readonly skippedFrames: number; readonly encodedFrames: number; + readonly canvasId: number; } export interface IVideoFactory { create(): IVideo; @@ -988,6 +989,29 @@ export interface IAudioTrackFactory { importLegacySettings(): void; saveLegacySettings(): void; } +export interface IAutoConfigResourcePercentile { + p50: number; + p95: number; +} +export interface IAutoConfigResourceGpu { + available: boolean; + vramUsedMB?: IAutoConfigResourcePercentile; + vramBudgetMB?: number; +} +export type AutoConfigResourcePhase = 'bandwidth' | 'stream_encoder' | 'recording_encoder'; +export interface IAutoConfigResourceUsage { + phase: AutoConfigResourcePhase; + sampleCount: number; + durationMs: number; + cpuPct: IAutoConfigResourcePercentile; + procRamMB: IAutoConfigResourcePercentile; + gpu: IAutoConfigResourceGpu; +} +export interface IAutoConfigSummary { + complete: boolean; + resourceUsage: IAutoConfigResourceUsage[]; + [key: string]: unknown; +} export declare const enum VCamOutputType { Invalid = 0, SceneOutput = 1, diff --git a/js/module.ts b/js/module.ts index 6e39424ca..a717a40f5 100644 --- a/js/module.ts +++ b/js/module.ts @@ -1470,6 +1470,11 @@ export interface IVideo { * Number of total encoded frames */ readonly encodedFrames: number; + + /** + * Server-side canvas id. Pass to APIs that reference video contexts by id. + */ + readonly canvasId: number; } export interface IVideoFactory { @@ -1956,6 +1961,48 @@ export interface IAudioTrackFactory { saveLegacySettings(): void; } +// ---- Autoconfig resource-usage telemetry ---- +// +// Shapes for the JSON payload of the autoconfig 'resource_usage' event, and +// the matching `resourceUsage` array inside GetAutoConfigSummary()'s JSON. +// +// p50 is the typical value during the phase; p95 is the sustained ceiling +// after dropping single-sample spikes from unrelated OS noise. min / max / avg +// are deliberately not exposed — max overweights one-off background activity +// and avg is hard to act on. + +export interface IAutoConfigResourcePercentile { + p50: number; + p95: number; +} + +export interface IAutoConfigResourceGpu { + available: boolean; + vramUsedMB?: IAutoConfigResourcePercentile; + vramBudgetMB?: number; +} + +export type AutoConfigResourcePhase = 'bandwidth' | 'stream_encoder' | 'recording_encoder'; + +export interface IAutoConfigResourceUsage { + phase: AutoConfigResourcePhase; + sampleCount: number; + durationMs: number; + cpuPct: IAutoConfigResourcePercentile; + procRamMB: IAutoConfigResourcePercentile; + gpu: IAutoConfigResourceGpu; +} + +// Parsed shape of NodeObs.GetAutoConfigSummary(). Only the fields the +// resource-usage feature consumes are typed; other historical fields +// (encoderDetection, videoDecision, bandwidthTest, selection) are present in +// the JSON but intentionally left as `unknown` — type them when you need them. +export interface IAutoConfigSummary { + complete: boolean; + resourceUsage: IAutoConfigResourceUsage[]; + [key: string]: unknown; +} + export const enum VCamOutputType { Invalid, SceneOutput, diff --git a/obs-studio-client/source/nodeobs_autoconfig.cpp b/obs-studio-client/source/nodeobs_autoconfig.cpp index 546beba19..0714178ea 100644 --- a/obs-studio-client/source/nodeobs_autoconfig.cpp +++ b/obs-studio-client/source/nodeobs_autoconfig.cpp @@ -19,6 +19,8 @@ #include "nodeobs_autoconfig.hpp" #include "polling-pacer.hpp" #include "shared.hpp" +#include "streaming.hpp" +#include bool autoConfig::isWorkerRunning = false; bool autoConfig::worker_stop = true; @@ -59,6 +61,10 @@ void autoConfig::worker() data->event = response[1].value_str; data->description = response[2].value_str; data->percentage = response[3].value_union.fp64; + // Optional 5th payload field (added for the POC UI). Older + // servers won't include it — guard the read. + if (response.size() >= 5) + data->payload = response[4].value_str; ac_queue_task_workers.push_back(new std::thread(&autoConfig::queueTask, data)); } } @@ -103,20 +109,39 @@ void autoConfig::stop_worker() Napi::Value autoConfig::InitializeAutoConfig(const Napi::CallbackInfo &info) { - Napi::Function async_callback = info[0].As(); - Napi::Object serverInfo = info[1].ToObject(); - std::string continent = serverInfo.Get("continent").ToString().Utf8Value(); - std::string service = serverInfo.Get("service_name").ToString().Utf8Value(); + if (info.Length() < 2 || !info[0].IsArray() || !info[1].IsFunction()) { + Napi::TypeError::New(info.Env(), "InitializeAutoConfig expects (streamings: IStreaming[], callback)") + .ThrowAsJavaScriptException(); + return info.Env().Undefined(); + } + + Napi::Array array = info[0].As(); + std::vector uids(array.Length()); + for (uint32_t i = 0; i < array.Length(); i++) { + if (!osn::TryUnwrapStreamingUid(array.Get(i), uids[i])) { + Napi::TypeError::New(info.Env(), "InitializeAutoConfig: streamings[i] is not an IStreaming instance") + .ThrowAsJavaScriptException(); + return info.Env().Undefined(); + } + } + std::vector uidsBin(uids.size() * sizeof(uint64_t)); + if (!uids.empty()) + memcpy(uidsBin.data(), uids.data(), uidsBin.size()); + + Napi::Function async_callback = info[1].As(); auto conn = GetConnection(info); if (!conn) return info.Env().Undefined(); - std::vector response = conn->call_synchronous_helper("AutoConfig", "InitializeAutoConfig", {continent, service}); + std::vector response = conn->call_synchronous_helper("AutoConfig", "InitializeAutoConfig", {ipc::value(uidsBin)}); if (!ValidateResponse(info, response)) return info.Env().Undefined(); + if (isWorkerRunning) + stop_worker(); + js_thread = Napi::ThreadSafeFunction::New(info.Env(), async_callback, "AutoConfig", 0, 1, [](Napi::Env) {}); start_worker(); @@ -178,6 +203,9 @@ void autoConfig::queueTask(AutoConfigInfo *data) if (event_data->event.compare("error") != 0) { result.Set(Napi::String::New(env, "percentage"), Napi::Number::New(env, event_data->percentage)); } + if (!event_data->payload.empty()) { + result.Set(Napi::String::New(env, "payload"), Napi::String::New(env, event_data->payload)); + } result.Set(Napi::String::New(env, "continent"), Napi::String::New(env, "")); jsCallback.Call({result}); @@ -265,6 +293,23 @@ Napi::Value autoConfig::StartSaveSettings(const Napi::CallbackInfo &info) return info.Env().Undefined(); } +Napi::Value autoConfig::GetAutoConfigSummary(const Napi::CallbackInfo &info) +{ + auto conn = GetConnection(info); + if (!conn) + return info.Env().Undefined(); + + std::vector response = conn->call_synchronous_helper("AutoConfig", "GetAutoConfigSummary", {}); + + if (!ValidateResponse(info, response)) + return info.Env().Undefined(); + + if (response.size() < 2) + return info.Env().Undefined(); + + return Napi::String::New(info.Env(), response[1].value_str); +} + Napi::Value autoConfig::TerminateAutoConfig(const Napi::CallbackInfo &info) { auto conn = GetConnection(info); @@ -293,4 +338,5 @@ void autoConfig::Init(Napi::Env env, Napi::Object exports) exports.Set(Napi::String::New(env, "StartSaveStreamSettings"), Napi::Function::New(env, autoConfig::StartSaveStreamSettings)); exports.Set(Napi::String::New(env, "StartSaveSettings"), Napi::Function::New(env, autoConfig::StartSaveSettings)); exports.Set(Napi::String::New(env, "TerminateAutoConfig"), Napi::Function::New(env, autoConfig::TerminateAutoConfig)); + exports.Set(Napi::String::New(env, "GetAutoConfigSummary"), Napi::Function::New(env, autoConfig::GetAutoConfigSummary)); } diff --git a/obs-studio-client/source/nodeobs_autoconfig.hpp b/obs-studio-client/source/nodeobs_autoconfig.hpp index c9db0a576..ef3cd7019 100644 --- a/obs-studio-client/source/nodeobs_autoconfig.hpp +++ b/obs-studio-client/source/nodeobs_autoconfig.hpp @@ -29,6 +29,10 @@ struct AutoConfigInfo { std::string event; std::string description; double percentage = 0; + // Optional JSON payload for new event types (bandwidth_result, + // selection_decision, video_decision, encoder_detection). Empty for legacy + // events. Surfaced to JS as a "payload" property when non-empty. + std::string payload; }; extern const char *ac_sem_name; @@ -62,4 +66,5 @@ Napi::Value StartSetDefaultSettings(const Napi::CallbackInfo &info); Napi::Value StartSaveStreamSettings(const Napi::CallbackInfo &info); Napi::Value StartSaveSettings(const Napi::CallbackInfo &info); Napi::Value TerminateAutoConfig(const Napi::CallbackInfo &info); +Napi::Value GetAutoConfigSummary(const Napi::CallbackInfo &info); } diff --git a/obs-studio-client/source/streaming.cpp b/obs-studio-client/source/streaming.cpp index 133084278..84ac2927e 100644 --- a/obs-studio-client/source/streaming.cpp +++ b/obs-studio-client/source/streaming.cpp @@ -24,6 +24,10 @@ #include "reconnect.hpp" #include "network.hpp" #include "video.hpp" +#include "simple-streaming.hpp" +#include "advanced-streaming.hpp" +#include "enhanced-broadcasting-simple-streaming.hpp" +#include "enhanced-broadcasting-advanced-streaming.hpp" void osn::Streaming::ReleaseObjects() { @@ -448,3 +452,40 @@ Napi::Value osn::Streaming::GetDataOutput(const Napi::CallbackInfo &info) return Napi::Number::New(info.Env(), response[1].value_union.fp64); } + +bool osn::TryUnwrapStreamingUid(const Napi::Value &value, uint64_t &out_uid) +{ + if (!value.IsObject()) + return false; + Napi::Object obj = value.As(); + + if (obj.InstanceOf(osn::SimpleStreaming::constructor.Value())) { + auto *s = Napi::ObjectWrap::Unwrap(obj); + if (s) { + out_uid = s->uid; + return true; + } + } + if (obj.InstanceOf(osn::AdvancedStreaming::constructor.Value())) { + auto *s = Napi::ObjectWrap::Unwrap(obj); + if (s) { + out_uid = s->uid; + return true; + } + } + if (obj.InstanceOf(osn::EnhancedBroadcastingSimpleStreaming::constructor.Value())) { + auto *s = Napi::ObjectWrap::Unwrap(obj); + if (s) { + out_uid = s->uid; + return true; + } + } + if (obj.InstanceOf(osn::EnhancedBroadcastingAdvancedStreaming::constructor.Value())) { + auto *s = Napi::ObjectWrap::Unwrap(obj); + if (s) { + out_uid = s->uid; + return true; + } + } + return false; +} diff --git a/obs-studio-client/source/streaming.hpp b/obs-studio-client/source/streaming.hpp index bda23125b..a670dbe46 100644 --- a/obs-studio-client/source/streaming.hpp +++ b/obs-studio-client/source/streaming.hpp @@ -65,4 +65,6 @@ class Streaming : public WorkerSignals { void Start(const Napi::CallbackInfo &info); void Stop(const Napi::CallbackInfo &info); }; + +bool TryUnwrapStreamingUid(const Napi::Value &value, uint64_t &out_uid); } diff --git a/obs-studio-client/source/video.cpp b/obs-studio-client/source/video.cpp index a769d3b92..bc0c15495 100644 --- a/obs-studio-client/source/video.cpp +++ b/obs-studio-client/source/video.cpp @@ -38,6 +38,7 @@ Napi::Object osn::Video::Init(Napi::Env env, Napi::Object exports) InstanceAccessor("skippedFrames", &osn::Video::GetSkippedFrames, nullptr), InstanceAccessor("encodedFrames", &osn::Video::GetEncodedFrames, nullptr), + InstanceAccessor("canvasId", &osn::Video::GetCanvasId, nullptr), }); exports.Set("Video", func); osn::Video::constructor = Napi::Persistent(func); @@ -105,10 +106,14 @@ void osn::Video::Destroy(const Napi::CallbackInfo &info) auto response = conn->call_synchronous_helper("Video", "RemoveVideoContext", {ipc::value((uint64_t)(this->canvasId))}); ValidateResponse(info, response); - isLastVideoValid = false; return; } +Napi::Value osn::Video::GetCanvasId(const Napi::CallbackInfo &info) +{ + return Napi::Number::New(info.Env(), (double)this->canvasId); +} + inline void CreateVideo(const Napi::CallbackInfo &info, const std::vector &response, Napi::Object &video, uint32_t index) { video.Set("fpsNum", response.at(index++).value_union.ui32); @@ -146,19 +151,16 @@ Napi::Value osn::Video::get(const Napi::CallbackInfo &info) if (!conn) return info.Env().Undefined(); - if (!isLastVideoValid) { - lastVideo = conn->call_synchronous_helper("Video", "GetVideoContext", {ipc::value((uint64_t)this->canvasId)}); + auto response = conn->call_synchronous_helper("Video", "GetVideoContext", {ipc::value((uint64_t)this->canvasId)}); - if (!ValidateResponse(info, lastVideo)) - return info.Env().Undefined(); + if (!ValidateResponse(info, response)) + return info.Env().Undefined(); - if (!(lastVideo.size() == 11 || lastVideo.size() == 12)) - return info.Env().Undefined(); - isLastVideoValid = true; - } + if (!(response.size() == 11 || response.size() == 12)) + return info.Env().Undefined(); Napi::Object video = Napi::Object::New(info.Env()); - CreateVideo(info, lastVideo, video, 1); + CreateVideo(info, response, video, 1); return video; } @@ -182,9 +184,6 @@ void osn::Video::set(const Napi::CallbackInfo &info, const Napi::Value &value) auto response = conn->call_synchronous_helper("Video", "SetVideoContext", args); - lastVideo.resize(0); - isLastVideoValid = false; - if (!ValidateResponse(info, response)) return; } diff --git a/obs-studio-client/source/video.hpp b/obs-studio-client/source/video.hpp index 4e264aabb..8934c431b 100644 --- a/obs-studio-client/source/video.hpp +++ b/obs-studio-client/source/video.hpp @@ -26,11 +26,6 @@ class Video : public Napi::ObjectWrap { uint64_t canvasId = 0; constexpr static uint64_t nonCavasId = std::numeric_limits::max(); -private: - std::vector lastVideo; - bool isLastVideoValid = false; - -public: static Napi::FunctionReference constructor; static Napi::Object Init(Napi::Env env, Napi::Object exports); Video(const Napi::CallbackInfo &info); @@ -46,5 +41,10 @@ class Video : public Napi::ObjectWrap { Napi::Value GetLegacySettings(const Napi::CallbackInfo &info); void SetLegacySettings(const Napi::CallbackInfo &info, const Napi::Value &value); + + // Read-only accessor exposing the server-side canvas id (the same value the + // server's osn::Video::Manager keys this object by). Required so the frontend + // can refer to a canvas in APIs like autoconfig that take ids. + Napi::Value GetCanvasId(const Napi::CallbackInfo &info); }; } diff --git a/obs-studio-server/CMakeLists.txt b/obs-studio-server/CMakeLists.txt index c8b7a1fcd..50c387f75 100644 --- a/obs-studio-server/CMakeLists.txt +++ b/obs-studio-server/CMakeLists.txt @@ -382,6 +382,8 @@ SET(osn-server_SOURCES "${PROJECT_SOURCE_DIR}/source/nodeobs_audio_encoders.h" "${PROJECT_SOURCE_DIR}/source/nodeobs_autoconfig.cpp" "${PROJECT_SOURCE_DIR}/source/nodeobs_autoconfig.h" + "${PROJECT_SOURCE_DIR}/source/nodeobs_autoconfig_resource_sampler.cpp" + "${PROJECT_SOURCE_DIR}/source/nodeobs_autoconfig_resource_sampler.h" "${PROJECT_SOURCE_DIR}/source/nodeobs_configManager.cpp" "${PROJECT_SOURCE_DIR}/source/nodeobs_configManager.hpp" "${PROJECT_SOURCE_DIR}/source/nodeobs_display.cpp" diff --git a/obs-studio-server/source/nodeobs_api.cpp b/obs-studio-server/source/nodeobs_api.cpp index f00dce2b2..9ed4552ab 100644 --- a/obs-studio-server/source/nodeobs_api.cpp +++ b/obs-studio-server/source/nodeobs_api.cpp @@ -29,6 +29,7 @@ #include "util/lexer.h" #include "util-crashmanager.h" #include "util-metricsprovider.h" +#include "nodeobs_service.h" #include "osn-streaming.hpp" #include "osn-recording.hpp" diff --git a/obs-studio-server/source/nodeobs_api.h b/obs-studio-server/source/nodeobs_api.h index 5a5509eb5..6eb415a0d 100644 --- a/obs-studio-server/source/nodeobs_api.h +++ b/obs-studio-server/source/nodeobs_api.h @@ -30,7 +30,7 @@ #include #include #include "nodeobs_configManager.hpp" -#include "nodeobs_service.h" +//#include "nodeobs_service.h" #include "util-osx.hpp" extern std::string g_moduleDirectory; diff --git a/obs-studio-server/source/nodeobs_autoconfig.cpp b/obs-studio-server/source/nodeobs_autoconfig.cpp index 581155a84..50d7e5071 100644 --- a/obs-studio-server/source/nodeobs_autoconfig.cpp +++ b/obs-studio-server/source/nodeobs_autoconfig.cpp @@ -17,11 +17,25 @@ ******************************************************************************/ #include "nodeobs_autoconfig.h" +#include "nodeobs_autoconfig_resource_sampler.h" +#include #include #include +#include +#include #include "osn-error.hpp" #include "shared.hpp" #include "osn-encoders.hpp" +#include +#include + +#include "osn-service.hpp" +#include "osn-simple-streaming.hpp" +#include "osn-advanced-streaming.hpp" +#include "osn-streaming-helpers.hpp" +#include "osn-recording.hpp" +#include "osn-video.hpp" +#include enum class Type { Invalid, Streaming, Recording }; @@ -29,6 +43,11 @@ enum class Service { Twitch, Hitbox, Beam, YouTube, Other }; enum class Encoder { x264, NVENC, QSV, AMD, Apple, Stream }; +// Forward decl — defined further down. Needed by GetAutoConfigSummary and the +// TestStreamEncoderThread encoder_detection event push, both of which sit above +// the definition site. +static inline const char *GetEncoderId(Encoder enc); + enum class Quality { Stream, High }; enum class FPSType : int { PreferHighFPS, PreferHighRes, UseCurrent, fps30, fps60 }; @@ -37,75 +56,145 @@ enum ThreadedTests : int { BandwidthTest, StreamEncoderTest, RecordingEncoderTes class AutoConfigInfo { public: - AutoConfigInfo(const std::string &a_event, const std::string &a_description, double a_percentage) + AutoConfigInfo(const std::string &a_event, const std::string &a_description, double a_percentage, const std::string &a_payload = "") { event = a_event; description = a_description; percentage = a_percentage; + payload = a_payload; }; ~AutoConfigInfo(){}; std::string event; std::string description; double percentage; + // Optional JSON payload for new event types (bandwidth_result, selection_decision, + // video_decision, encoder_detection). Legacy events leave it empty. Surfaced as + // the 5th rval of Query() — legacy frontends read 4 fields and ignore it. + std::string payload; }; std::array, ThreadedTests::Count> asyncTests; std::mutex eventsMutex; std::queue events; -Service serviceSelected = Service::Other; -Quality recordingQuality = Quality::Stream; -Encoder recordingEncoder = Encoder::Stream; -Encoder streamingEncoder = Encoder::x264; -Type type = Type::Streaming; -FPSType fpsType = FPSType::PreferHighFPS; -uint64_t idealBitrate = 4500; -uint64_t baseResolutionCX = 1920; -uint64_t baseResolutionCY = 1080; -uint64_t idealResolutionCX = 1280; -uint64_t idealResolutionCY = 720; -int idealFPSNum = 60; -int idealFPSDen = 1; -std::string serviceName; -std::string serverName; -std::string server; -std::string key; - -bool hardwareEncodingAvailable = false; -bool nvencAvailable = false; -bool qsvAvailable = false; -bool vceAvailable = false; -bool appleAvailable = false; - -int startingBitrate = 4500; -bool customServer = false; -bool bandwidthTest = true; -bool testRegions = true; - -bool regionNA = false; -bool regionSA = false; -bool regionEU = false; -bool regionAS = false; -bool regionOC = false; - -bool preferHighFPS = true; -bool preferHardware = true; -int specificFPSNum = 0; -int specificFPSDen = 0; +// Per-run context. One autoconfig run at a time. Targets are passed in by the +// frontend via InitializeAutoConfig; chosen values are stored here as each stage +// runs and are pushed to the live objects in applyResults(). +struct AutoconfigRun { + // Streaming targets the frontend asked us to run autoconfig against. + // Populated from InitializeAutoConfig's argument and consumed by the + // bandwidth test / apply phase. Empty means no targets were provided. + std::vector targetStreamingIds; + + // Inputs / options. + Type type = Type::Streaming; + FPSType fpsType = FPSType::PreferHighFPS; + bool preferHardware = true; + bool preferHighFPS = true; + bool bandwidthTest = true; + bool customServer = false; + int specificFPSNum = 0; + int specificFPSDen = 0; + uint64_t baseResolutionCX = 1920; + uint64_t baseResolutionCY = 1080; + int startingBitrate = 4500; + + // Detected encoder availability (filled by TestHardwareEncoding). + bool hardwareEncodingAvailable = false; + bool nvencAvailable = false; + bool qsvAvailable = false; + bool vceAvailable = false; + bool appleAvailable = false; + bool softwareTested = false; + + // Chosen values (filled by each test stage; consumed by applyResults). + Quality recordingQuality = Quality::Stream; + Encoder recordingEncoder = Encoder::Stream; + Encoder streamingEncoder = Encoder::x264; + uint64_t idealBitrate = 4500; + uint64_t idealResolutionCX = 1280; + uint64_t idealResolutionCY = 720; + int idealFPSNum = 60; + int idealFPSDen = 1; + std::string server; + + struct TargetResult { + uint64_t streamingId = UINT64_MAX; + uint64_t idealBitrate = 0; + std::string server; + }; + std::vector targetResults; + + // Per-target bandwidth-test diagnostics. Captured in TestBandwidthThreadV2 + // and surfaced via the bandwidth_result event + GetAutoConfigSummary IPC. + struct BandwidthDetail { + uint64_t targetId = UINT64_MAX; + int testBitrate = 0; + int platformCapProbed = 0; + uint64_t measuredKbps = 0; + int droppedFrames = 0; + int totalFrames = 0; + uint64_t totalBytes = 0; + int elapsedMs = 0; + std::string serverTested; + }; + std::vector bandwidthDetails; + + // Per-target selection breakdown. Captured in applyResults; surfaced via the + // selection_decision event + summary IPC. + struct SelectionDetail { + uint64_t targetId = UINT64_MAX; + int userBitrate = 0; + uint64_t heuristic = 0; + uint64_t choseBeforeCaps = 0; + uint64_t afterMeasuredCap = 0; + uint64_t afterPlatformCap = 0; + uint64_t picked = 0; + std::string bindingCap; + std::string appliedServer; + std::string currentEncoderId; + std::string chosenEncoderId; + bool encoderChanged = false; + }; + std::vector selectionDetails; + + // Per-phase resource samples (CPU%, process RAM, optionally GPU VRAM). + // Captured by ResourceSampler around the bandwidth and encoder test phases; + // surfaced via the resource_usage event + summary IPC. Pure telemetry — no + // influence on the selection heuristics in this version. + std::vector resourceWindows; + + // Per-canvas video-context decision. Captured in applyResults. + struct VideoDecision { + void *contextPtr = nullptr; + uint32_t cxBefore = 0, cyBefore = 0; + uint32_t fpsNumBefore = 0, fpsDenBefore = 0; + uint32_t cxAfter = 0, cyAfter = 0; + uint32_t fpsNumAfter = 0, fpsDenAfter = 0; + int obsSetVideoInfoRet = 0; + bool skipped = false; + }; + std::vector videoDecisions; + + // True once SaveSettings() finished. GetAutoConfigSummary uses it to set the + // JSON's "complete" flag so the POC UI can tell whether the data is final. + bool runComplete = false; +}; + +static AutoconfigRun runContext; std::condition_variable cv; std::mutex m; bool cancel = false; bool started = false; -bool softwareTested = false; - struct ServerInfo { std::string name; std::string address; int bitrate = 0; int ms = -1; + size_t targetIndex = 0; inline ServerInfo() {} @@ -115,8 +204,7 @@ void autoConfig::Register(ipc::server &srv) { std::shared_ptr cls = std::make_shared("AutoConfig"); - cls->register_function(std::make_shared("InitializeAutoConfig", std::vector{ipc::type::String, ipc::type::String}, - autoConfig::InitializeAutoConfig)); + cls->register_function(std::make_shared("InitializeAutoConfig", std::vector{ipc::type::Binary}, autoConfig::InitializeAutoConfig)); cls->register_function(std::make_shared("StartBandwidthTest", std::vector{}, autoConfig::StartBandwidthTest)); cls->register_function(std::make_shared("StartStreamEncoderTest", std::vector{}, autoConfig::StartStreamEncoderTest)); cls->register_function(std::make_shared("StartRecordingEncoderTest", std::vector{}, autoConfig::StartRecordingEncoderTest)); @@ -126,6 +214,7 @@ void autoConfig::Register(ipc::server &srv) cls->register_function(std::make_shared("StartSaveSettings", std::vector{}, autoConfig::StartSaveSettings)); cls->register_function(std::make_shared("TerminateAutoConfig", std::vector{}, autoConfig::TerminateAutoConfig)); cls->register_function(std::make_shared("Query", std::vector{}, autoConfig::Query)); + cls->register_function(std::make_shared("GetAutoConfigSummary", std::vector{}, autoConfig::GetAutoConfigSummary)); srv.register_collection(cls); } @@ -152,17 +241,76 @@ void autoConfig::WaitPendingTests(double timeout) } } +// Serialize a ResourceWindow to JSON and emit a resource_usage event. The window +// is also pushed onto runContext.resourceWindows so GetAutoConfigSummary can +// re-emit it later. Frontends can consume either the event stream or the summary. +static std::string resourceWindowToJson(const autoConfig::ResourceWindow &w) +{ + obs_data_t *root = obs_data_create(); + obs_data_set_string(root, "phase", w.phase.c_str()); + obs_data_set_int(root, "sampleCount", w.sampleCount); + obs_data_set_int(root, "durationMs", w.durationMs); + + // p50 is the typical value during the window; p95 is the sustained ceiling + // after dropping single-sample outliers (a background process briefly using + // CPU shouldn't dominate the report). + auto putPct = [&](const char *key, double p50, double p95) { + obs_data_t *o = obs_data_create(); + obs_data_set_double(o, "p50", p50); + obs_data_set_double(o, "p95", p95); + obs_data_set_obj(root, key, o); + obs_data_release(o); + }; + auto putPctInt = [&](obs_data_t *parent, const char *key, uint64_t p50, uint64_t p95) { + obs_data_t *o = obs_data_create(); + obs_data_set_int(o, "p50", (long long)p50); + obs_data_set_int(o, "p95", (long long)p95); + obs_data_set_obj(parent, key, o); + obs_data_release(o); + }; + + putPct("cpuPct", w.p50Sample.cpuPct, w.p95Sample.cpuPct); + putPct("procRamMB", w.p50Sample.procRamMB, w.p95Sample.procRamMB); + + obs_data_t *gpu = obs_data_create(); + obs_data_set_bool(gpu, "available", w.gpuAvailable); + if (w.gpuAvailable) { + putPctInt(gpu, "vramUsedMB", w.p50Sample.gpuVramUsedMB, w.p95Sample.gpuVramUsedMB); + // Budget is platform-driven and effectively constant across a window — + // surface a single number rather than a percentile pair. + obs_data_set_int(gpu, "vramBudgetMB", (long long)w.p95Sample.gpuVramBudgetMB); + } + obs_data_set_obj(root, "gpu", gpu); + obs_data_release(gpu); + + std::string json = obs_data_get_json(root); + obs_data_release(root); + return json; +} + +static void recordResourceWindow(const autoConfig::ResourceWindow &w) +{ + if (w.sampleCount <= 0) + return; + + runContext.resourceWindows.push_back(w); + + std::string payload = resourceWindowToJson(w); + std::lock_guard lock(eventsMutex); + events.push(AutoConfigInfo("resource_usage", w.phase, 100, payload)); +} + void autoConfig::TestHardwareEncoding(void) { size_t idx = 0; const char *id; while (obs_enum_encoder_types(idx++, &id)) { if (strcmp(id, ADVANCED_ENCODER_NVENC) == 0) - hardwareEncodingAvailable = nvencAvailable = true; + runContext.hardwareEncodingAvailable = runContext.nvencAvailable = true; else if (strcmp(id, ADVANCED_ENCODER_QSV) == 0) - hardwareEncodingAvailable = qsvAvailable = true; + runContext.hardwareEncodingAvailable = runContext.qsvAvailable = true; else if (strcmp(id, ADVANCED_ENCODER_AMD) == 0) - hardwareEncodingAvailable = vceAvailable = true; + runContext.hardwareEncodingAvailable = runContext.vceAvailable = true; #ifdef __APPLE__ else if (strcmp(id, APPLE_HARDWARE_VIDEO_ENCODER_M1) == 0 #ifndef __aarch64__ @@ -170,7 +318,7 @@ void autoConfig::TestHardwareEncoding(void) #endif ) if (__builtin_available(macOS 13.0, *)) - hardwareEncodingAvailable = appleAvailable = true; + runContext.hardwareEncodingAvailable = runContext.appleAvailable = true; #endif } } @@ -186,91 +334,91 @@ static inline void string_depad_key(std::string &key) } } -bool autoConfig::CanTestServer(const char *server) -{ - if (!testRegions || (regionNA && regionSA && regionEU && regionAS && regionOC)) - return true; - - if (serviceSelected == Service::Twitch) { - if (astrcmp_n(server, "NA:", 3) == 0 || astrcmp_n(server, "US West:", 8) == 0 || astrcmp_n(server, "US East:", 8) == 0 || - astrcmp_n(server, "US Central:", 11) == 0) { - return regionNA; - } else if (astrcmp_n(server, "South America:", 14) == 0) { - return regionSA; - } else if (astrcmp_n(server, "EU:", 3) == 0) { - return regionEU; - } else if (astrcmp_n(server, "Asia:", 5) == 0) { - return regionAS; - } else if (astrcmp_n(server, "Australia:", 10) == 0) { - return regionOC; - } else { - return true; - } - } else if (serviceSelected == Service::Hitbox) { - if (strcmp(server, "Default") == 0) { - return true; - } else if (astrcmp_n(server, "US-West:", 8) == 0 || astrcmp_n(server, "US-East:", 8) == 0) { - return regionNA; - } else if (astrcmp_n(server, "South America:", 14) == 0) { - return regionSA; - } else if (astrcmp_n(server, "EU-", 3) == 0) { - return regionEU; - } else if (astrcmp_n(server, "South Korea:", 12) == 0 || astrcmp_n(server, "Asia:", 5) == 0 || astrcmp_n(server, "China:", 6) == 0) { - return regionAS; - } else if (astrcmp_n(server, "Oceania:", 8) == 0) { - return regionOC; - } else { - return true; - } - } else if (serviceSelected == Service::Beam) { - if (astrcmp_n(server, "US:", 3) == 0 || astrcmp_n(server, "Canada:", 7) || astrcmp_n(server, "Mexico:", 7)) { - return regionNA; - } else if (astrcmp_n(server, "Brazil:", 7) == 0) { - return regionSA; - } else if (astrcmp_n(server, "EU:", 3) == 0) { - return regionEU; - } else if (astrcmp_n(server, "South Korea:", 12) == 0 || astrcmp_n(server, "Asia:", 5) == 0 || astrcmp_n(server, "India:", 6) == 0) { - return regionAS; - } else if (astrcmp_n(server, "Australia:", 10) == 0) { - return regionOC; - } else { - return true; - } - } else { - return true; - } - - return false; -} - -void GetServers(std::vector &servers) -{ - OBSData settings = obs_data_create(); - obs_data_release(settings); - // obs_data_set_string(settings, "service", wiz->serviceName.c_str()); - //FIX ME - obs_data_set_string(settings, "service", serviceName.c_str()); - - obs_properties_t *ppts = obs_get_service_properties("rtmp_common"); - obs_property_t *p = obs_properties_get(ppts, "service"); - obs_property_modified(p, settings); - - p = obs_properties_get(ppts, "server"); - size_t count = obs_property_list_item_count(p); - servers.reserve(count); - - for (size_t i = 0; i < count; i++) { - const char *name = obs_property_list_item_name(p, i); - const char *server = obs_property_list_item_string(p, i); - - if (autoConfig::CanTestServer(name)) { - ServerInfo info(name, server); - servers.push_back(info); - } - } - - obs_properties_destroy(ppts); -} +// bool autoConfig::CanTestServer(const char *server) +// { +// if (!testRegions || (regionNA && regionSA && regionEU && regionAS && regionOC)) +// return true; + +// if (serviceSelected == Service::Twitch) { +// if (astrcmp_n(server, "NA:", 3) == 0 || astrcmp_n(server, "US West:", 8) == 0 || astrcmp_n(server, "US East:", 8) == 0 || +// astrcmp_n(server, "US Central:", 11) == 0) { +// return regionNA; +// } else if (astrcmp_n(server, "South America:", 14) == 0) { +// return regionSA; +// } else if (astrcmp_n(server, "EU:", 3) == 0) { +// return regionEU; +// } else if (astrcmp_n(server, "Asia:", 5) == 0) { +// return regionAS; +// } else if (astrcmp_n(server, "Australia:", 10) == 0) { +// return regionOC; +// } else { +// return true; +// } +// } else if (serviceSelected == Service::Hitbox) { +// if (strcmp(server, "Default") == 0) { +// return true; +// } else if (astrcmp_n(server, "US-West:", 8) == 0 || astrcmp_n(server, "US-East:", 8) == 0) { +// return regionNA; +// } else if (astrcmp_n(server, "South America:", 14) == 0) { +// return regionSA; +// } else if (astrcmp_n(server, "EU-", 3) == 0) { +// return regionEU; +// } else if (astrcmp_n(server, "South Korea:", 12) == 0 || astrcmp_n(server, "Asia:", 5) == 0 || astrcmp_n(server, "China:", 6) == 0) { +// return regionAS; +// } else if (astrcmp_n(server, "Oceania:", 8) == 0) { +// return regionOC; +// } else { +// return true; +// } +// } else if (serviceSelected == Service::Beam) { +// if (astrcmp_n(server, "US:", 3) == 0 || astrcmp_n(server, "Canada:", 7) || astrcmp_n(server, "Mexico:", 7)) { +// return regionNA; +// } else if (astrcmp_n(server, "Brazil:", 7) == 0) { +// return regionSA; +// } else if (astrcmp_n(server, "EU:", 3) == 0) { +// return regionEU; +// } else if (astrcmp_n(server, "South Korea:", 12) == 0 || astrcmp_n(server, "Asia:", 5) == 0 || astrcmp_n(server, "India:", 6) == 0) { +// return regionAS; +// } else if (astrcmp_n(server, "Australia:", 10) == 0) { +// return regionOC; +// } else { +// return true; +// } +// } else { +// return true; +// } + +// return false; +// } + +// void GetServers(std::vector &servers) +// { +// OBSData settings = obs_data_create(); +// obs_data_release(settings); +// // obs_data_set_string(settings, "service", wiz->serviceName.c_str()); +// //FIX ME +// obs_data_set_string(settings, "service", serviceName.c_str()); + +// obs_properties_t *ppts = obs_get_service_properties("rtmp_common"); +// obs_property_t *p = obs_properties_get(ppts, "service"); +// obs_property_modified(p, settings); + +// p = obs_properties_get(ppts, "server"); +// size_t count = obs_property_list_item_count(p); +// servers.reserve(count); + +// for (size_t i = 0; i < count; i++) { +// const char *name = obs_property_list_item_name(p, i); +// const char *server = obs_property_list_item_string(p, i); + +// if (autoConfig::CanTestServer(name)) { +// ServerInfo info(name, server); +// servers.push_back(info); +// } +// } + +// obs_properties_destroy(ppts); +// } void start_next_step(void (*task)(), std::string event, std::string description, int percentage) { @@ -302,12 +450,165 @@ void autoConfig::Query(void *data, const int64_t id, const std::vector &args, std::vector &rval) +{ + // Build a structured JSON summary of the most recent autoconfig run for the + // new POC UI. Safe to call before `done` — `complete` flag indicates whether + // the data is final. Reset to empty on InitializeAutoConfig. + obs_data_t *root = obs_data_create(); + obs_data_set_bool(root, "complete", runContext.runComplete); + + // encoderDetection + { + const char *chosenStream = GetEncoderId(runContext.streamingEncoder); + const char *chosenRecording = GetEncoderId(runContext.recordingEncoder); + obs_data_t *enc = obs_data_create(); + obs_data_set_bool(enc, "hardwareEncodingAvailable", runContext.hardwareEncodingAvailable); + obs_data_set_bool(enc, "nvenc", runContext.nvencAvailable); + obs_data_set_bool(enc, "qsv", runContext.qsvAvailable); + obs_data_set_bool(enc, "vce", runContext.vceAvailable); + obs_data_set_bool(enc, "apple", runContext.appleAvailable); + obs_data_set_bool(enc, "softwareTested", runContext.softwareTested); + obs_data_set_string(enc, "chosenStreamingEncoder", chosenStream ? chosenStream : ""); + obs_data_set_string(enc, "chosenRecordingEncoder", chosenRecording ? chosenRecording : ""); + obs_data_set_string(enc, "recordingQuality", runContext.recordingQuality == Quality::High ? "High" : "Stream"); + obs_data_set_obj(root, "encoderDetection", enc); + obs_data_release(enc); + } + + // videoDecision + { + obs_data_t *video = obs_data_create(); + obs_data_t *chosen = obs_data_create(); + obs_data_set_int(chosen, "cx", (long long)runContext.idealResolutionCX); + obs_data_set_int(chosen, "cy", (long long)runContext.idealResolutionCY); + obs_data_set_int(chosen, "fpsNum", runContext.idealFPSNum); + obs_data_set_int(chosen, "fpsDen", runContext.idealFPSDen); + obs_data_set_obj(video, "chosen", chosen); + obs_data_release(chosen); + + obs_data_array_t *perCanvas = obs_data_array_create(); + for (auto &vd : runContext.videoDecisions) { + obs_data_t *item = obs_data_create(); + std::ostringstream ptrOss; + ptrOss << "0x" << std::hex << reinterpret_cast(vd.contextPtr); + obs_data_set_string(item, "contextPtr", ptrOss.str().c_str()); + + obs_data_t *before = obs_data_create(); + obs_data_set_int(before, "cx", vd.cxBefore); + obs_data_set_int(before, "cy", vd.cyBefore); + obs_data_set_int(before, "fpsNum", vd.fpsNumBefore); + obs_data_set_int(before, "fpsDen", vd.fpsDenBefore); + obs_data_set_obj(item, "before", before); + obs_data_release(before); + + obs_data_t *after = obs_data_create(); + obs_data_set_int(after, "cx", vd.cxAfter); + obs_data_set_int(after, "cy", vd.cyAfter); + obs_data_set_int(after, "fpsNum", vd.fpsNumAfter); + obs_data_set_int(after, "fpsDen", vd.fpsDenAfter); + obs_data_set_obj(item, "after", after); + obs_data_release(after); + + obs_data_set_int(item, "obsSetVideoInfoRet", vd.obsSetVideoInfoRet); + obs_data_set_bool(item, "skipped", vd.skipped); + obs_data_array_push_back(perCanvas, item); + obs_data_release(item); + } + obs_data_set_array(video, "perCanvas", perCanvas); + obs_data_array_release(perCanvas); + + obs_data_set_obj(root, "videoDecision", video); + obs_data_release(video); + } + + // bandwidthTest + { + obs_data_t *bw = obs_data_create(); + obs_data_array_t *perTarget = obs_data_array_create(); + for (auto &bd : runContext.bandwidthDetails) { + obs_data_t *item = obs_data_create(); + obs_data_set_int(item, "targetId", (long long)bd.targetId); + obs_data_set_int(item, "testBitrate", bd.testBitrate); + obs_data_set_int(item, "platformCapProbed", bd.platformCapProbed); + obs_data_set_int(item, "measuredKbps", (long long)bd.measuredKbps); + obs_data_set_int(item, "droppedFrames", bd.droppedFrames); + obs_data_set_int(item, "totalFrames", bd.totalFrames); + obs_data_set_int(item, "totalBytes", (long long)bd.totalBytes); + obs_data_set_int(item, "elapsedMs", bd.elapsedMs); + obs_data_set_string(item, "serverTested", bd.serverTested.c_str()); + obs_data_array_push_back(perTarget, item); + obs_data_release(item); + } + obs_data_set_array(bw, "perTarget", perTarget); + obs_data_array_release(perTarget); + + obs_data_set_obj(root, "bandwidthTest", bw); + obs_data_release(bw); + } + + // selection + { + obs_data_t *sel = obs_data_create(); + obs_data_array_t *perTarget = obs_data_array_create(); + for (auto &sd : runContext.selectionDetails) { + obs_data_t *item = obs_data_create(); + obs_data_set_int(item, "targetId", (long long)sd.targetId); + obs_data_set_int(item, "userBitrate", sd.userBitrate); + obs_data_set_int(item, "heuristic", (long long)sd.heuristic); + obs_data_set_int(item, "choseBeforeCaps", (long long)sd.choseBeforeCaps); + obs_data_set_int(item, "afterMeasuredCap", (long long)sd.afterMeasuredCap); + obs_data_set_int(item, "afterPlatformCap", (long long)sd.afterPlatformCap); + obs_data_set_int(item, "picked", (long long)sd.picked); + obs_data_set_string(item, "bindingCap", sd.bindingCap.c_str()); + obs_data_set_string(item, "appliedServer", sd.appliedServer.c_str()); + obs_data_set_string(item, "currentEncoderId", sd.currentEncoderId.c_str()); + obs_data_set_string(item, "chosenEncoderId", sd.chosenEncoderId.c_str()); + obs_data_set_bool(item, "encoderChanged", sd.encoderChanged); + obs_data_array_push_back(perTarget, item); + obs_data_release(item); + } + obs_data_set_array(sel, "perTarget", perTarget); + obs_data_array_release(perTarget); + + obs_data_set_obj(root, "selection", sel); + obs_data_release(sel); + } + + // resourceUsage — per-phase CPU/RAM (and Windows-only GPU VRAM) samples + // captured during the bandwidth and encoder test phases. Same JSON shape + // as the resource_usage event payload. + { + obs_data_array_t *windows = obs_data_array_create(); + for (auto &w : runContext.resourceWindows) { + std::string s = resourceWindowToJson(w); + obs_data_t *item = obs_data_create_from_json(s.c_str()); + if (item) { + obs_data_array_push_back(windows, item); + obs_data_release(item); + } + } + obs_data_set_array(root, "resourceUsage", windows); + obs_data_array_release(windows); + } + + std::string json = obs_data_get_json_pretty(root); + obs_data_release(root); + + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); + rval.push_back(ipc::value(json)); + AUTO_DEBUG; +} + void autoConfig::StopThread(void) { std::unique_lock ul(m); @@ -317,14 +618,25 @@ void autoConfig::StopThread(void) void autoConfig::InitializeAutoConfig(void *data, const int64_t id, const std::vector &args, std::vector &rval) { - serverName = "Auto (Recommended)"; - server = "auto"; + runContext = AutoconfigRun{}; + cancel = false; - obs_output_t *streamOutput = OBS_service::getStreamingOutput(StreamServiceId::Main); - if (streamOutput) - OBS_service::setStreamingOutput(nullptr, StreamServiceId::Main); + // Drain leftover events from a prior run. Otherwise a stopping_step queued + // by an aborted bandwidth thread (e.g. after TerminateAutoConfig) leaks into + // the next session's first drainUntil() and confuses callers. + { + std::lock_guard lock(eventsMutex); + while (!events.empty()) + events.pop(); + } - cancel = false; + if (!args.empty()) { + const std::vector &bin = args[0].value_bin; + size_t n = bin.size() / sizeof(uint64_t); + runContext.targetStreamingIds.resize(n); + if (n > 0) + memcpy(runContext.targetStreamingIds.data(), bin.data(), n * sizeof(uint64_t)); + } rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); AUTO_DEBUG; @@ -332,7 +644,16 @@ void autoConfig::InitializeAutoConfig(void *data, const int64_t id, const std::v void autoConfig::StartBandwidthTest(void *data, const int64_t id, const std::vector &args, std::vector &rval) { - asyncTests[ThreadedTests::BandwidthTest] = std::async(std::launch::async, TestBandwidthThread); + if (asyncTests[ThreadedTests::BandwidthTest].valid()) + asyncTests[ThreadedTests::BandwidthTest].wait(); + + { + std::lock_guard lock(eventsMutex); + while (!events.empty()) + events.pop(); + } + + asyncTests[ThreadedTests::BandwidthTest] = std::async(std::launch::async, TestBandwidthThreadV2); rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); AUTO_DEBUG; @@ -454,11 +775,11 @@ int EvaluateBandwidth(ServerInfo &server, bool &connected, bool &stopped, bool & bitrate = (uint64_t)total_bytes * 8U * 1000000000U / total_time / 1000U; } - startingBitrate = (int)obs_data_get_int(vencoder_settings, "bitrate"); - if (obs_output_get_frames_dropped(output) || (int)bitrate < (startingBitrate * 75 / 100)) { + runContext.startingBitrate = (int)obs_data_get_int(vencoder_settings, "bitrate"); + if (obs_output_get_frames_dropped(output) || (int)bitrate < (runContext.startingBitrate * 75 / 100)) { server.bitrate = (int)bitrate * 70 / 100; } else { - server.bitrate = startingBitrate; + server.bitrate = runContext.startingBitrate; } server.ms = obs_output_get_connect_time_ms(output); @@ -477,50 +798,321 @@ void sendErrorMessage(const std::string &message) eventsMutex.unlock(); } -void autoConfig::TestBandwidthThread(void) +int autoConfig::GetStartingBitrate(const std::string &serviceName) { - eventsMutex.lock(); - events.push(AutoConfigInfo("starting_step", "bandwidth_test", 0)); - eventsMutex.unlock(); + OBSData service_settings = obs_data_create(); + obs_data_release(service_settings); - bool connected = false; - bool stopped = false; - bool errorOnStop = false; + obs_data_set_string(service_settings, "service", serviceName.c_str()); + + OBSService service = obs_service_create("rtmp_common", "temp_service", service_settings, nullptr); + obs_service_release(service); + + int bitrate = 10000; + + OBSData settings = obs_data_create(); + obs_data_release(settings); + obs_data_set_int(settings, "bitrate", bitrate); + obs_service_apply_encoder_settings(service, settings, nullptr); + + int startingBitrate = (int)obs_data_get_int(settings, "bitrate"); + return startingBitrate; +} + +void autoConfig::TestBandwidthThreadV2(void) +{ bool gotError = false; + std::vector testResults; - obs_video_info video = {0}; - bool have_users_info = obs_get_video_info(&video); + { + std::lock_guard lock(eventsMutex); + events.push(AutoConfigInfo("starting_step", "bandwidth_test", 0)); + } - obs_video_info *ovi = obs_create_video_info(); + // Resolve the streaming targets the frontend passed to InitializeAutoConfig. + // Skip ids that no longer resolve (object was destroyed between Initialize + // and the bandwidth test) or that have no service set. + std::vector targets; + std::vector targetIds; + + for (uint64_t uid : runContext.targetStreamingIds) { + osn::Streaming *s = osn::IStreaming::Manager::GetInstance().find(uid); + if (s && s->service) { + targets.push_back(s); + targetIds.push_back(uid); + } + } - if (!have_users_info) { - video = *ovi; - video.fps_num = 60; - video.fps_den = 1; - } else { - video.fps_num = ovi->fps_num; - video.fps_den = ovi->fps_den; + if (targets.empty()) { + sendErrorMessage("no_streaming_targets_provided"); + gotError = true; } - video.base_width = 1280; - video.base_height = 720; - video.output_width = 128; - video.output_height = 128; + if (!gotError) { + std::vector testingServices; + std::vector testingServiceTargetIdx; + + for (size_t i = 0; i < targets.size(); i++) { + const char *type = osn::streaming_helpers::getStreamOutputType(targets[i]->service); + if (!type) + type = "rtmp_output"; + std::string outputName = "autoconfig_bw_" + std::to_string(i); + targets[i]->CreateOutput(type, outputName); + + // Pick a high test bitrate so the measurement reflects the link's + // real ceiling rather than whatever low value the user has set. + // - user's current bitrate (might already be high) + // - platform cap probed via obs_service_apply_encoder_settings + // (Twitch returns 6000 for non-partners; partners higher) + // - 6000 fallback when no platform hook exists (custom RTMP, etc.) + int userBitrate = 0; + if (targets[i]->videoEncoder) { + obs_data_t *s = obs_encoder_get_settings(targets[i]->videoEncoder); + userBitrate = (int)obs_data_get_int(s, "bitrate"); + obs_data_release(s); + } + int platformCap = 0; + if (targets[i]->service) { + obs_data_t *probe = obs_data_create(); + obs_data_set_int(probe, "bitrate", 50000); + obs_service_apply_encoder_settings(targets[i]->service, probe, nullptr); + int capped = (int)obs_data_get_int(probe, "bitrate"); + if (capped > 0 && capped < 50000) + platformCap = capped; + obs_data_release(probe); + } + int testBitrate = std::max({userBitrate, platformCap, 6000}); + blog(LOG_INFO, "TestBandwidthV2: target %zu test bitrate %d (user=%d, platformCap=%d)", i, testBitrate, userBitrate, platformCap); + + // Pre-record the test setup; measurement fields filled below. + AutoconfigRun::BandwidthDetail bd; + bd.targetId = targetIds[i]; + bd.testBitrate = testBitrate; + bd.platformCapProbed = platformCap; + runContext.bandwidthDetails.push_back(bd); + + targets[i]->testBandwidth(gotError, testBitrate); + + if (!gotError && targets[i]->GetOutput()) { + testingServices.push_back(targets[i]); + testingServiceTargetIdx.push_back(i); + } else if (targets[i]->GetOutput() && obs_output_active(targets[i]->GetOutput())) { + obs_output_stop(targets[i]->GetOutput()); + } + } - int ret = obs_set_video_info(ovi, &video); - if (ret != OBS_VIDEO_SUCCESS) { - eventsMutex.lock(); - events.push(AutoConfigInfo("error", "invalid_video_settings", 0)); - eventsMutex.unlock(); - obs_remove_video_info(ovi); - return; + if (!gotError && !testingServices.empty()) { + auto startTime = std::chrono::steady_clock::now(); + bool allConnected = false; + + // Wait up to 10 seconds for all services to connect or fail. + while (!allConnected && !gotError && std::chrono::steady_clock::now() - startTime < std::chrono::seconds(10)) { + allConnected = true; + + for (auto *streaming : testingServices) { + std::string signal = streaming->testQuery(); + + if (signal == "error") { + gotError = true; + break; + } else if (signal == "start" || signal == "starting" || signal == "activate" || signal == "reconnect" || + signal == "reconnect_success") { + allConnected = false; + } + } + + std::unique_lock ul(m); + if (cancel) { + gotError = true; + break; + } + ul.unlock(); + + if (!allConnected && !gotError) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + } + + // Let the outputs stream for a few seconds so data accumulates + // before we sample obs_output_get_total_bytes(). Without this + // the loop above exits as soon as signals are drained (often + // < 100 ms after the RTMP connection opens) and totalBytes is 0. + int dataWaitMs = 0; + autoConfig::ResourceSampler sampler; + if (!gotError && allConnected) { + const int targetWaitMs = 5000; + auto dataStart = std::chrono::steady_clock::now(); + sampler.start("bandwidth"); + while (std::chrono::steady_clock::now() - dataStart < std::chrono::milliseconds(targetWaitMs)) { + std::unique_lock ul(m); + if (cancel) { + gotError = true; + break; + } + ul.unlock(); + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + sampler.sample(); + } + dataWaitMs = (int)std::chrono::duration_cast(std::chrono::steady_clock::now() - dataStart).count(); + recordResourceWindow(sampler.stop()); + } + + if (!gotError) { + for (size_t si = 0; si < testingServices.size(); si++) { + auto *streaming = testingServices[si]; + if (streaming->GetOutput() && obs_output_active(streaming->GetOutput())) { + uint64_t totalBytes = obs_output_get_total_bytes(streaming->GetOutput()); + int connectTimeMs = obs_output_get_connect_time_ms(streaming->GetOutput()); + int droppedFrames = obs_output_get_frames_dropped(streaming->GetOutput()); + int totalFrames = obs_output_get_total_frames(streaming->GetOutput()); + + obs_output_stop(streaming->GetOutput()); + + if (totalBytes > 0) { + // connectTimeMs may be 0 for localhost; fall back + // to the data-wait duration for bitrate estimation. + int elapsedMs = connectTimeMs > 0 ? connectTimeMs : std::max(dataWaitMs, 1); + uint64_t bitrate = (totalBytes * 8ULL * 1000ULL) / static_cast(elapsedMs) / 1000ULL; + + std::string serverAddress; + if (streaming->service) { + serverAddress = + obs_service_get_connect_info(streaming->service, OBS_SERVICE_CONNECT_INFO_SERVER_URL); + } + + ServerInfo result; + result.address = serverAddress; + result.ms = elapsedMs; + result.targetIndex = testingServiceTargetIdx[si]; + + // Use the per-target test bitrate (still active on the encoder + // until CleanTestMode runs below) as the reference, not the + // global runContext.startingBitrate which doesn't reflect the + // per-target ceiling-search override. + int testBitrateRef = 0; + if (streaming->videoEncoder) { + obs_data_t *encSettings = obs_encoder_get_settings(streaming->videoEncoder); + testBitrateRef = (int)obs_data_get_int(encSettings, "bitrate"); + obs_data_release(encSettings); + } + if (testBitrateRef <= 0) + testBitrateRef = runContext.startingBitrate; + + if (droppedFrames > 0 || (int)bitrate < (testBitrateRef * 75 / 100)) { + result.bitrate = (int)bitrate * 70 / 100; + } else { + result.bitrate = testBitrateRef; + } + + testResults.push_back(result); + + // Fill the measurement side of the per-target detail record + // (setup side was filled before testBandwidth) and emit the + // bandwidth_result event with a JSON payload for the new UI. + size_t targetIdx = testingServiceTargetIdx[si]; + if (targetIdx < runContext.bandwidthDetails.size()) { + auto &bd = runContext.bandwidthDetails[targetIdx]; + bd.measuredKbps = bitrate; + bd.droppedFrames = droppedFrames; + bd.totalFrames = totalFrames; + bd.totalBytes = totalBytes; + bd.elapsedMs = elapsedMs; + bd.serverTested = serverAddress; + + obs_data_t *p = obs_data_create(); + obs_data_set_int(p, "targetId", (long long)bd.targetId); + obs_data_set_int(p, "testBitrate", bd.testBitrate); + obs_data_set_int(p, "platformCapProbed", bd.platformCapProbed); + obs_data_set_int(p, "measuredKbps", (long long)bd.measuredKbps); + obs_data_set_int(p, "droppedFrames", bd.droppedFrames); + obs_data_set_int(p, "totalFrames", bd.totalFrames); + obs_data_set_int(p, "totalBytes", (long long)bd.totalBytes); + obs_data_set_int(p, "elapsedMs", bd.elapsedMs); + obs_data_set_string(p, "serverTested", bd.serverTested.c_str()); + std::string payload = obs_data_get_json(p); + obs_data_release(p); + + std::lock_guard lock(eventsMutex); + events.push(AutoConfigInfo("bandwidth_result", "target_" + std::to_string(bd.targetId), 100, + payload)); + } + } + } + } + } + } + + for (auto *streaming : targets) { + streaming->CleanTestMode(); + } } + if (!gotError) { + if (testResults.empty()) { + sendErrorMessage("no_valid_bandwidth_results"); + gotError = true; + } else { + // Build per-target results. Each target picks its best server + // (highest bitrate, lowest latency). + runContext.targetResults.clear(); + for (size_t ti = 0; ti < targetIds.size(); ti++) { + std::vector targetSpecific; + for (auto &r : testResults) { + if (r.targetIndex == ti) + targetSpecific.push_back(r); + } + if (targetSpecific.empty()) + continue; + + std::sort(targetSpecific.begin(), targetSpecific.end(), [](const ServerInfo &a, const ServerInfo &b) { + return (a.bitrate > b.bitrate) || (a.bitrate == b.bitrate && a.ms < b.ms); + }); + + AutoconfigRun::TargetResult tr; + tr.streamingId = targetIds[ti]; + tr.idealBitrate = targetSpecific.front().bitrate; + tr.server = targetSpecific.front().address; + runContext.targetResults.push_back(tr); + } + + // Global idealBitrate = minimum across all targets (conservative + // for shared canvas resolution/FPS selection). + if (!runContext.targetResults.empty()) { + uint64_t minBitrate = UINT64_MAX; + for (auto &tr : runContext.targetResults) { + if (tr.idealBitrate < minBitrate) + minBitrate = tr.idealBitrate; + } + runContext.idealBitrate = minBitrate; + runContext.server = runContext.targetResults[0].server; + } + } + } + + { + std::lock_guard lock(eventsMutex); + events.push(AutoConfigInfo("stopping_step", "bandwidth_test", 100)); + } +} + +// Old implementation - commented out as it has compilation errors +// This will be removed after V2 development is finished +#ifdef OLD_BANDWIDTH_TEST //deprecated +void autoConfig::TestBandwidthThread(void) +{ + eventsMutex.lock(); + events.push(AutoConfigInfo("starting_step", "bandwidth_test", 0)); + eventsMutex.unlock(); + + bool connected = false; + bool stopped = false; + bool errorOnStop = false; + bool gotError = false; + const char *serverType = "rtmp_common"; OBSEncoder vencoder = obs_video_encoder_create(ADVANCED_ENCODER_X264, "test_x264", nullptr, nullptr); OBSEncoder aencoder = obs_audio_encoder_create("ffmpeg_aac", "test_aac", nullptr, 0, nullptr); - OBSService service = obs_service_create(serverType, "test_service", nullptr, nullptr); OBSOutput output = obs_output_create("rtmp_output", "test_stream", nullptr, nullptr); /* -----------------------------------*/ @@ -533,91 +1125,130 @@ void autoConfig::TestBandwidthThread(void) // output: "bind_ip" via main config -> "Output", "BindIP" // obs_output_set_service - OBSData service_settings = obs_data_create(); OBSData vencoder_settings = obs_data_create(); OBSData aencoder_settings = obs_data_create(); OBSData output_settings = obs_data_create(); - obs_data_release(service_settings); obs_data_release(vencoder_settings); obs_data_release(aencoder_settings); obs_data_release(output_settings); - obs_service_t *currentService = OBS_service::getService(StreamServiceId::Main); - if (currentService) { - obs_data_t *currentServiceSettings = obs_service_get_settings(currentService); - if (currentServiceSettings) { - if (serviceName.compare("") == 0) - serviceName = obs_data_get_string(currentServiceSettings, "service"); - - key = obs_service_get_connect_info(currentService, OBS_SERVICE_CONNECT_INFO_STREAM_KEY); - if (key.empty()) { + osn::Service::Manager::GetInstance().for_each([&gotError](obs_service_t *service) { + Service serviceType = Service::Other; + std::string key; + std::string keyToEvaluate; + std::string serviceName; + OBSData service_settings = obs_data_create(); + obs_data_release(service_settings); + + if (service) { + obs_service_t *currentService = service; + if (currentService) { + obs_data_t *currentServiceSettings = obs_service_get_settings(currentService); + if (currentServiceSettings) { + serviceName = obs_data_get_string(currentServiceSettings, "service"); + + key = obs_service_get_connect_info(currentService, OBS_SERVICE_CONNECT_INFO_STREAM_KEY); + if (key.empty()) { + sendErrorMessage("invalid_stream_settings"); + gotError = true; + } + } else { + sendErrorMessage("invalid_stream_settings"); + gotError = true; + } + } else { sendErrorMessage("invalid_stream_settings"); gotError = true; } - } else { - sendErrorMessage("invalid_stream_settings"); - gotError = true; + if (gotError) { + return; + } + + if (serviceName == "Twitch") + serviceType = Service::Twitch; + else if (serviceName == "hitbox.tv") + serviceType = Service::Hitbox; + else if (serviceName == "beam.pro") + serviceType = Service::Beam; + else if (serviceName.find("YouTube") != std::string::npos) + serviceType = Service::YouTube; + else + serviceType = Service::Other; + + keyToEvaluate = key; + + if (serviceType == Service::Twitch) { + string_depad_key(key); + keyToEvaluate += "?bandwidthtest"; + } + + // todo - will it work without making it custom server? + // if (serviceType == Service::YouTube) { + // serverName = "Stream URL"; + // server = obs_service_get_connect_info(currentService, OBS_SERVICE_CONNECT_INFO_SERVER_URL); + // } + + obs_data_set_string(service_settings, "service", serviceName.c_str()); + obs_data_set_string(service_settings, "key", keyToEvaluate.c_str()); + + int awstartingBitrate = GetStartingBitrate(serviceName); } - } else { - sendErrorMessage("invalid_stream_settings"); - gotError = true; - } + }); if (gotError) { obs_output_release(output); obs_encoder_release(vencoder); obs_encoder_release(aencoder); - obs_service_release(service); - obs_remove_video_info(ovi); return; } - if (!customServer) { - if (serviceName == "Twitch") - serviceSelected = Service::Twitch; - else if (serviceName == "hitbox.tv") - serviceSelected = Service::Hitbox; - else if (serviceName == "beam.pro") - serviceSelected = Service::Beam; - else if (serviceName.find("YouTube") != std::string::npos) - serviceSelected = Service::YouTube; - else - serviceSelected = Service::Other; - } else { - serviceSelected = Service::Other; - } - std::string keyToEvaluate = key; - - if (serviceSelected == Service::Twitch) { - string_depad_key(key); - keyToEvaluate += "?bandwidthtest"; - } - - if (serviceSelected == Service::YouTube) { - serverName = "Stream URL"; - server = obs_service_get_connect_info(currentService, OBS_SERVICE_CONNECT_INFO_SERVER_URL); - } - - obs_data_set_string(service_settings, "service", serviceName.c_str()); - obs_data_set_string(service_settings, "key", keyToEvaluate.c_str()); + // if (!customServer) { + // if (serviceName == "Twitch") + // serviceSelected = Service::Twitch; + // else if (serviceName == "hitbox.tv") + // serviceSelected = Service::Hitbox; + // else if (serviceName == "beam.pro") + // serviceSelected = Service::Beam; + // else if (serviceName.find("YouTube") != std::string::npos) + // serviceSelected = Service::YouTube; + // else + // serviceSelected = Service::Other; + // } else { + // serviceSelected = Service::Other; + // } + //std::string keyToEvaluate = key; + + // if (serviceSelected == Service::Twitch) { + // string_depad_key(key); + // keyToEvaluate += "?bandwidthtest"; + // } + + // if (serviceSelected == Service::YouTube) { + // serverName = "Stream URL"; + // server = obs_service_get_connect_info(currentService, OBS_SERVICE_CONNECT_INFO_SERVER_URL); + // } + + // obs_data_set_string(service_settings, "service", serviceName.c_str()); + // obs_data_set_string(service_settings, "key", keyToEvaluate.c_str()); //Setting starting bitrate - OBSData service_settingsawd = obs_data_create(); - obs_data_release(service_settingsawd); + // OBSData service_settingsawd = obs_data_create(); + // obs_data_release(service_settingsawd); - obs_data_set_string(service_settingsawd, "service", serviceName.c_str()); + // obs_data_set_string(service_settingsawd, "service", serviceName.c_str()); - OBSService servicewad = obs_service_create(serverType, "temp_service", service_settingsawd, nullptr); - obs_service_release(servicewad); + // OBSService servicewad = obs_service_create(serverType, "temp_service", service_settingsawd, nullptr); + // obs_service_release(servicewad); - int bitrate = 10000; + // int bitrate = 10000; - OBSData settings = obs_data_create(); - obs_data_release(settings); - obs_data_set_int(settings, "bitrate", bitrate); - obs_service_apply_encoder_settings(servicewad, settings, nullptr); + // OBSData settings = obs_data_create(); + // obs_data_release(settings); + // obs_data_set_int(settings, "bitrate", bitrate); + // obs_service_apply_encoder_settings(servicewad, settings, nullptr); + + // int awstartingBitrate = (int)obs_data_get_int(settings, "bitrate"); - int awstartingBitrate = (int)obs_data_get_int(settings, "bitrate"); obs_data_set_int(vencoder_settings, "bitrate", awstartingBitrate); obs_data_set_string(vencoder_settings, "rate_control", "CBR"); obs_data_set_string(vencoder_settings, "preset", "veryfast"); @@ -625,21 +1256,22 @@ void autoConfig::TestBandwidthThread(void) obs_data_set_int(aencoder_settings, "bitrate", 32); + //todo get bind if from new api const char *bind_ip = config_get_string(ConfigManager::getInstance().getBasic(), "Output", "BindIP"); obs_data_set_string(output_settings, "bind_ip", bind_ip); /* -----------------------------------*/ /* determine which servers to test */ - std::vector servers; - if (customServer) - servers.emplace_back(server.c_str(), server.c_str()); - else - GetServers(servers); + // std::vector servers; + // if (customServer) + // servers.emplace_back(server.c_str(), server.c_str()); + // else + // GetServers(servers); /* just use the first server if it only has one alternate server */ - if (servers.size() < 3) - servers.resize(1); + // if (servers.size() < 3) + // servers.resize(1); /* -----------------------------------*/ /* apply settings */ @@ -738,13 +1370,13 @@ void autoConfig::TestBandwidthThread(void) events.push(AutoConfigInfo("progress", "bandwidth_test", 100)); eventsMutex.unlock(); } - } else { - for (size_t i = 0; i < servers.size(); i++) { - EvaluateBandwidth(servers[i], connected, stopped, success, errorOnStop, service_settings, service, output, vencoder_settings); - eventsMutex.lock(); - events.push(AutoConfigInfo("progress", "bandwidth_test", (double)(i + 1) * 100 / servers.size())); - eventsMutex.unlock(); - } + // } else { + // for (size_t i = 0; i < servers.size(); i++) { + // EvaluateBandwidth(servers[i], connected, stopped, success, errorOnStop, service_settings, service, output, vencoder_settings); + // eventsMutex.lock(); + // events.push(AutoConfigInfo("progress", "bandwidth_test", (double)(i + 1) * 100 / servers.size())); + // eventsMutex.unlock(); + // } } if (!success && !gotError) { @@ -755,29 +1387,24 @@ void autoConfig::TestBandwidthThread(void) } if (!gotError) { - for (auto &server : servers) { - bool close = abs(server.bitrate - bestBitrate) < 400; - - if ((!close && server.bitrate > bestBitrate) || (close && server.ms < bestMS)) { - bestServer = server.address; - bestServerName = server.name; - bestBitrate = server.bitrate; - bestMS = server.ms; - } - } - server = bestServer; + // for (auto &server : servers) { + // bool close = abs(server.bitrate - bestBitrate) < 400; + + // if ((!close && server.bitrate > bestBitrate) || (close && server.ms < bestMS)) { + // bestServer = server.address; + // bestServerName = server.name; + // bestBitrate = server.bitrate; + // bestMS = server.ms; + // } + // } + runContext.server = bestServer; serverName = bestServerName; - idealBitrate = bestBitrate; + runContext.idealBitrate = bestBitrate; } obs_output_release(output); obs_encoder_release(vencoder); obs_encoder_release(aencoder); - obs_service_release(service); - ret = obs_remove_video_info(ovi); - if (ret != OBS_VIDEO_SUCCESS) { - blog(LOG_ERROR, "[VIDEO_CANVAS] failed to remove video canvas %08X", ovi); - } if (!gotError) { eventsMutex.lock(); @@ -785,6 +1412,7 @@ void autoConfig::TestBandwidthThread(void) eventsMutex.unlock(); } } +#endif //deprecated /* this is used to estimate the lower bitrate limit for a given * resolution/fps. yes, it is a totally arbitrary equation that gets @@ -798,7 +1426,7 @@ static long double EstimateBitrateVal(int cx, int cy, int fps_num, int fps_den) static long double EstimateMinBitrate(int cx, int cy, int fps_num, int fps_den) { - long double val = EstimateBitrateVal((int)baseResolutionCX, (int)baseResolutionCY, 60, 1) / 5800.0l; + long double val = EstimateBitrateVal((int)runContext.baseResolutionCX, (int)runContext.baseResolutionCY, 60, 1) / 5800.0l; if (val < std::numeric_limits::epsilon() && val > -std::numeric_limits::epsilon()) { return 0.0; } @@ -827,15 +1455,15 @@ struct Result { void autoConfig::FindIdealHardwareResolution() { - int baseCX = (int)baseResolutionCX; - int baseCY = (int)baseResolutionCY; + int baseCX = (int)runContext.baseResolutionCX; + int baseCY = (int)runContext.baseResolutionCY; std::vector results; int pcores = os_get_physical_cores(); int maxDataRate; if (pcores >= 4) { - maxDataRate = int(baseResolutionCX * baseResolutionCY * 60 + 1000); + maxDataRate = int(runContext.baseResolutionCX * runContext.baseResolutionCY * 60 + 1000); } else { maxDataRate = 1280 * 720 * 30 + 1000; } @@ -845,8 +1473,8 @@ void autoConfig::FindIdealHardwareResolution() return; if (!fps_num || !fps_den) { - fps_num = specificFPSNum; - fps_den = specificFPSDen; + fps_num = runContext.specificFPSNum; + fps_den = runContext.specificFPSDen; } long double fps = ((long double)fps_num / (long double)fps_den); @@ -859,13 +1487,13 @@ void autoConfig::FindIdealHardwareResolution() return; int minBitrate = int(EstimateMinBitrate(cx, cy, fps_num, fps_den) * 114 / 100); - if (type == Type::Recording) + if (runContext.type == Type::Recording) force = true; - if (force || idealBitrate >= minBitrate) + if (force || runContext.idealBitrate >= minBitrate) results.emplace_back(cx, cy, fps_num, fps_den); }; - if (specificFPSNum && specificFPSDen) { + if (runContext.specificFPSNum && runContext.specificFPSDen) { testRes(1.0, 0, 0, false); testRes(1.5, 0, 0, false); testRes(1.0 / 0.6, 0, 0, false); @@ -886,7 +1514,7 @@ void autoConfig::FindIdealHardwareResolution() int minArea = 960 * 540 + 1000; - if (!specificFPSNum && preferHighFPS && results.size() > 1) { + if (!runContext.specificFPSNum && runContext.preferHighFPS && results.size() > 1) { Result &result1 = results[0]; Result &result2 = results[1]; @@ -898,16 +1526,11 @@ void autoConfig::FindIdealHardwareResolution() } Result result = results.front(); - idealResolutionCX = result.cx; - idealResolutionCY = result.cy; + runContext.idealResolutionCX = result.cx; + runContext.idealResolutionCY = result.cy; - if (idealResolutionCX * idealResolutionCY > 1280 * 720) { - idealResolutionCX = 1280; - idealResolutionCY = 720; - } - - idealFPSNum = result.fps_num; - idealFPSDen = result.fps_den; + runContext.idealFPSNum = result.fps_num; + runContext.idealFPSDen = result.fps_den; } bool autoConfig::TestSoftwareEncoding() @@ -925,9 +1548,9 @@ bool autoConfig::TestSoftwareEncoding() obs_data_release(vencoder_settings); obs_data_set_int(aencoder_settings, "bitrate", 32); - if (type != Type::Recording) { + if (runContext.type != Type::Recording) { obs_data_set_int(vencoder_settings, "keyint_sec", 2); - obs_data_set_int(vencoder_settings, "bitrate", idealBitrate); + obs_data_set_int(vencoder_settings, "bitrate", runContext.idealBitrate); obs_data_set_string(vencoder_settings, "rate_control", "CBR"); obs_data_set_string(vencoder_settings, "profile", "main"); obs_data_set_string(vencoder_settings, "preset", "veryfast"); @@ -971,8 +1594,8 @@ bool autoConfig::TestSoftwareEncoding() /* -----------------------------------*/ /* calculate starting resolution */ - int baseCX = int(baseResolutionCX); - int baseCY = int(baseResolutionCY); + int baseCX = int(runContext.baseResolutionCX); + int baseCY = int(runContext.baseResolutionCY); /* -----------------------------------*/ /* calculate starting test rates */ @@ -982,15 +1605,15 @@ bool autoConfig::TestSoftwareEncoding() int maxDataRate; if (lcores > 8 || pcores > 4) { /* superb */ - maxDataRate = int(baseResolutionCX * baseResolutionCY * 60 + 1000); + maxDataRate = int(runContext.baseResolutionCX * runContext.baseResolutionCY * 60 + 1000); } else if (lcores > 4 && pcores == 4) { /* great */ - maxDataRate = int(baseResolutionCX * baseResolutionCY * 60 + 1000); + maxDataRate = int(runContext.baseResolutionCX * runContext.baseResolutionCY * 60 + 1000); } else if (pcores == 4) { /* okay */ - maxDataRate = int(baseResolutionCX * baseResolutionCY * 30 + 1000); + maxDataRate = int(runContext.baseResolutionCX * runContext.baseResolutionCY * 30 + 1000); } else { /* toaster */ @@ -1014,8 +1637,8 @@ bool autoConfig::TestSoftwareEncoding() return true; if (!fps_num || !fps_den) { - fps_num = specificFPSNum; - fps_den = specificFPSDen; + fps_num = runContext.specificFPSNum; + fps_den = runContext.specificFPSDen; } long double fps = ((long double)fps_num / (long double)fps_den); @@ -1023,9 +1646,9 @@ bool autoConfig::TestSoftwareEncoding() int cx = int((long double)baseCX / div); int cy = int((long double)baseCY / div); - if (!force && type != Type::Recording) { + if (!force && runContext.type != Type::Recording) { int est = int(EstimateMinBitrate(cx, cy, fps_num, fps_den)); - if (est > idealBitrate) + if (est > runContext.idealBitrate) return true; } @@ -1076,7 +1699,7 @@ bool autoConfig::TestSoftwareEncoding() return !cancel; }; - if (specificFPSNum && specificFPSDen) { + if (runContext.specificFPSNum && runContext.specificFPSDen) { count = 5; if (!testRes(1.0, 0, 0, false)) return false; @@ -1117,7 +1740,7 @@ bool autoConfig::TestSoftwareEncoding() int minArea = 960 * 540 + 1000; - if (!specificFPSNum && preferHighFPS && results.size() > 1) { + if (!runContext.specificFPSNum && runContext.preferHighFPS && results.size() > 1) { Result &result1 = results[0]; Result &result2 = results[1]; @@ -1129,28 +1752,23 @@ bool autoConfig::TestSoftwareEncoding() } Result result = results.front(); - idealResolutionCX = result.cx; - idealResolutionCY = result.cy; - - if (idealResolutionCX * idealResolutionCY > 1280 * 720) { - idealResolutionCX = 1280; - idealResolutionCY = 720; - } + runContext.idealResolutionCX = result.cx; + runContext.idealResolutionCY = result.cy; - idealFPSNum = result.fps_num; - idealFPSDen = result.fps_den; + runContext.idealFPSNum = result.fps_num; + runContext.idealFPSDen = result.fps_den; long double fUpperBitrate = EstimateUpperBitrate(result.cx, result.cy, result.fps_num, result.fps_den); int upperBitrate = int(floor(fUpperBitrate / 50.0l) * 50.0l); - if (streamingEncoder != Encoder::x264) { + if (runContext.streamingEncoder != Encoder::x264) { upperBitrate *= 114; upperBitrate /= 100; } - if (idealBitrate > upperBitrate) - idealBitrate = upperBitrate; + if (runContext.idealBitrate > upperBitrate) + runContext.idealBitrate = upperBitrate; obs_output_release(output); obs_encoder_release(vencoder); @@ -1161,92 +1779,120 @@ bool autoConfig::TestSoftwareEncoding() blog(LOG_ERROR, "[VIDEO_CANVAS] Failed to remove video info after TestSoftwareEncoding, %08X", ovi); } - softwareTested = true; + runContext.softwareTested = true; return true; } void autoConfig::TestStreamEncoderThread() { eventsMutex.lock(); - events.push(AutoConfigInfo("starting_step", "streamingEncoder_test", 0)); + events.push(AutoConfigInfo("starting_step", "runContext.streamingEncoder_test", 0)); eventsMutex.unlock(); + autoConfig::ResourceSampler sampler; + sampler.start("stream_encoder", std::chrono::milliseconds(250)); + TestHardwareEncoding(); - if (!softwareTested) { - if (!preferHardware || !hardwareEncodingAvailable) { + if (!runContext.softwareTested) { + if (!runContext.preferHardware || !runContext.hardwareEncodingAvailable) { if (!TestSoftwareEncoding()) { return; } } } - if (preferHardware && !softwareTested && hardwareEncodingAvailable) + if (runContext.preferHardware && !runContext.softwareTested && runContext.hardwareEncodingAvailable) FindIdealHardwareResolution(); - if (!softwareTested) { - if (nvencAvailable) - streamingEncoder = Encoder::NVENC; - else if (qsvAvailable) - streamingEncoder = Encoder::QSV; - else if (vceAvailable) - streamingEncoder = Encoder::AMD; + if (!runContext.softwareTested) { + if (runContext.nvencAvailable) + runContext.streamingEncoder = Encoder::NVENC; + else if (runContext.qsvAvailable) + runContext.streamingEncoder = Encoder::QSV; + else if (runContext.vceAvailable) + runContext.streamingEncoder = Encoder::AMD; // HW encoding seems to not be stable on Mac // else if (appleHWAvailable) - // streamingEncoder = Encoder::appleHW; + // runContext.streamingEncoder = Encoder::appleHW; } else { - streamingEncoder = Encoder::x264; + runContext.streamingEncoder = Encoder::x264; } + // Surface encoder detection + chosen streaming encoder for the new POC UI. + { + const char *chosenId = GetEncoderId(runContext.streamingEncoder); + obs_data_t *p = obs_data_create(); + obs_data_set_bool(p, "hardwareEncodingAvailable", runContext.hardwareEncodingAvailable); + obs_data_set_bool(p, "nvenc", runContext.nvencAvailable); + obs_data_set_bool(p, "qsv", runContext.qsvAvailable); + obs_data_set_bool(p, "vce", runContext.vceAvailable); + obs_data_set_bool(p, "apple", runContext.appleAvailable); + obs_data_set_bool(p, "softwareTested", runContext.softwareTested); + obs_data_set_string(p, "chosenStreamingEncoder", chosenId ? chosenId : ""); + std::string payload = obs_data_get_json(p); + obs_data_release(p); + + std::lock_guard lock(eventsMutex); + events.push(AutoConfigInfo("encoder_detection", "summary", 100, payload)); + } + + recordResourceWindow(sampler.stop()); + eventsMutex.lock(); - events.push(AutoConfigInfo("stopping_step", "streamingEncoder_test", 100)); + events.push(AutoConfigInfo("stopping_step", "runContext.streamingEncoder_test", 100)); eventsMutex.unlock(); } void autoConfig::TestRecordingEncoderThread() { eventsMutex.lock(); - events.push(AutoConfigInfo("starting_step", "recordingEncoder_test", 0)); + events.push(AutoConfigInfo("starting_step", "runContext.recordingEncoder_test", 0)); eventsMutex.unlock(); + autoConfig::ResourceSampler sampler; + sampler.start("recording_encoder", std::chrono::milliseconds(250)); + TestHardwareEncoding(); - if (!hardwareEncodingAvailable && !softwareTested) { + if (!runContext.hardwareEncodingAvailable && !runContext.softwareTested) { if (!TestSoftwareEncoding()) { return; } } - if (type == Type::Recording && hardwareEncodingAvailable) + if (runContext.type == Type::Recording && runContext.hardwareEncodingAvailable) FindIdealHardwareResolution(); - recordingQuality = Quality::High; + runContext.recordingQuality = Quality::High; - bool recordingOnly = type == Type::Recording; + bool recordingOnly = runContext.type == Type::Recording; - if (hardwareEncodingAvailable) { - if (nvencAvailable) - recordingEncoder = Encoder::NVENC; - else if (qsvAvailable) - recordingEncoder = Encoder::QSV; - else if (vceAvailable) - recordingEncoder = Encoder::AMD; + if (runContext.hardwareEncodingAvailable) { + if (runContext.nvencAvailable) + runContext.recordingEncoder = Encoder::NVENC; + else if (runContext.qsvAvailable) + runContext.recordingEncoder = Encoder::QSV; + else if (runContext.vceAvailable) + runContext.recordingEncoder = Encoder::AMD; // HW encoding seems to not be stable on Mac // else if (appleHWAvailable) - // recordingEncoder = Encoder::appleHW; + // runContext.recordingEncoder = Encoder::appleHW; } else { - recordingEncoder = Encoder::x264; + runContext.recordingEncoder = Encoder::x264; } - if (recordingEncoder != Encoder::NVENC) { + if (runContext.recordingEncoder != Encoder::NVENC) { if (!recordingOnly) { - recordingEncoder = Encoder::Stream; - recordingQuality = Quality::Stream; + runContext.recordingEncoder = Encoder::Stream; + runContext.recordingQuality = Quality::Stream; } } + recordResourceWindow(sampler.stop()); + eventsMutex.lock(); - events.push(AutoConfigInfo("stopping_step", "recordingEncoder_test", 100)); + events.push(AutoConfigInfo("stopping_step", "runContext.recordingEncoder_test", 100)); eventsMutex.unlock(); } @@ -1268,6 +1914,7 @@ inline const char *GetEncoderId(Encoder enc) bool autoConfig::CheckSettings(void) { +#ifdef OLD_BANDWIDTH_TEST //deprecated OBSData settings = obs_data_create(); obs_data_set_string(settings, "service", serviceName.c_str()); @@ -1301,9 +1948,9 @@ bool autoConfig::CheckSettings(void) video.base_width = 1280; video.base_height = 720; - video.output_width = (uint32_t)idealResolutionCX; - video.output_height = (uint32_t)idealResolutionCY; - video.fps_num = idealFPSNum; + video.output_width = (uint32_t)runContext.idealResolutionCX; + video.output_height = (uint32_t)runContext.idealResolutionCY; + video.fps_num = runContext.idealFPSNum; video.fps_den = 1; video.initialized = true; int ret = obs_set_video_info(ovi, &video); @@ -1315,7 +1962,7 @@ bool autoConfig::CheckSettings(void) return false; } - OBSEncoder vencoder = obs_video_encoder_create(GetEncoderId(streamingEncoder), "test_encoder", nullptr, nullptr); + OBSEncoder vencoder = obs_video_encoder_create(GetEncoderId(runContext.streamingEncoder), "test_encoder", nullptr, nullptr); OBSEncoder aencoder = obs_audio_encoder_create("ffmpeg_aac", "test_aac", nullptr, 0, nullptr); OBSOutput output = obs_output_create("rtmp_output", "test_stream", nullptr, nullptr); @@ -1328,7 +1975,7 @@ bool autoConfig::CheckSettings(void) obs_data_release(aencoder_settings); obs_data_release(output_settings); - obs_data_set_int(vencoder_settings, "bitrate", idealBitrate); + obs_data_set_int(vencoder_settings, "bitrate", runContext.idealBitrate); obs_data_set_string(vencoder_settings, "rate_control", "CBR"); obs_data_set_string(vencoder_settings, "preset", "veryfast"); obs_data_set_int(vencoder_settings, "keyint_sec", 2); @@ -1426,6 +2073,8 @@ bool autoConfig::CheckSettings(void) blog(LOG_ERROR, "[VIDEO_CANVAS] Failed to remove video info after CheckSettings, %08X", ovi); } return success; +#endif //deprecated old api bandwidth test + return true; } void autoConfig::SetDefaultSettings(void) @@ -1434,90 +2083,305 @@ void autoConfig::SetDefaultSettings(void) events.push(AutoConfigInfo("starting_step", "setting_default_settings", 0)); eventsMutex.unlock(); - idealResolutionCX = 1280; - idealResolutionCY = 720; - idealFPSNum = 30; - recordingQuality = Quality::High; - idealBitrate = 4500; - streamingEncoder = Encoder::x264; - recordingEncoder = Encoder::Stream; + runContext.idealResolutionCX = 1280; + runContext.idealResolutionCY = 720; + runContext.idealFPSNum = 30; + runContext.recordingQuality = Quality::High; + runContext.idealBitrate = 4500; + runContext.streamingEncoder = Encoder::x264; + runContext.recordingEncoder = Encoder::Stream; eventsMutex.lock(); events.push(AutoConfigInfo("stopping_step", "setting_default_settings", 100)); eventsMutex.unlock(); } -void autoConfig::SaveStreamSettings() +// Push the chosen values from runContext into the streaming targets the +// frontend passed to InitializeAutoConfig. No basic.ini writes — APIv2 contract +// is that the frontend re-fetches via Get*() after seeing the "done" event. +// +// Order matters: obs_set_video_info fails while any output is still active, so we +// stop test outputs first, then mutate the video context, then service / encoders. +static void applyResults() { - /* ---------------------------------- */ - /* save service */ + // Resolve the streaming targets stored in runContext. Skip ids that no + // longer resolve (object was destroyed mid-run). + struct StreamingTarget { + osn::Streaming *streaming; + uint64_t id; + }; + std::vector streamingTargets; - eventsMutex.lock(); - events.push(AutoConfigInfo("starting_step", "saving_service", 0)); - eventsMutex.unlock(); + for (uint64_t uid : runContext.targetStreamingIds) { + osn::Streaming *s = osn::IStreaming::Manager::GetInstance().find(uid); + if (s) + streamingTargets.push_back({s, uid}); + } - const char *service_id = "rtmp_common"; + // Collect video contexts referenced by the streaming targets (deduplicated). + std::set videoContexts; + for (auto &st : streamingTargets) { + obs_video_info *v = st.streaming->GetCanvas(); + if (v) + videoContexts.insert(v); + } - obs_service_t *oldService = OBS_service::getService(StreamServiceId::Main); - OBSData hotkeyData = obs_hotkeys_save_service(oldService); - obs_data_release(hotkeyData); + // Defensive — stop all streaming outputs before touching video context. + for (auto &st : streamingTargets) { + if (st.streaming->GetOutput() && obs_output_active(st.streaming->GetOutput())) + obs_output_stop(st.streaming->GetOutput()); + } - OBSData settings = obs_data_create(); + // 1. Resolution / FPS — applied to each video context referenced by a streaming + // target. Must run before encoders (encoder video-mix indices are tied to the + // video context). + for (auto *video : videoContexts) { + AutoconfigRun::VideoDecision vd; + vd.contextPtr = video; + vd.cxBefore = video->output_width; + vd.cyBefore = video->output_height; + vd.fpsNumBefore = video->fps_num; + vd.fpsDenBefore = video->fps_den; + + obs_video_info v = *video; + v.fps_num = (uint32_t)runContext.idealFPSNum; + v.fps_den = (uint32_t)(runContext.idealFPSDen ? runContext.idealFPSDen : 1); + v.output_width = ((uint32_t)runContext.idealResolutionCX) & 0xFFFFFFFC; + v.output_height = ((uint32_t)runContext.idealResolutionCY) & 0xFFFFFFFE; + + vd.cxAfter = v.output_width; + vd.cyAfter = v.output_height; + vd.fpsNumAfter = v.fps_num; + vd.fpsDenAfter = v.fps_den; + + blog(LOG_INFO, "applyResults: ctx=%p current=%ux%u@%u/%u requested=%ux%u@%u/%u", video, video->output_width, video->output_height, + video->fps_num, video->fps_den, v.output_width, v.output_height, v.fps_num, v.fps_den); + + // Skip the libobs call when nothing changes — the common case where + // autoconfig picks the resolution/FPS the canvas is already running at. + // obs_set_video_info would otherwise return OBS_VIDEO_CURRENTLY_ACTIVE + // for any active video context (e.g. running preview). + if (video->fps_num == v.fps_num && video->fps_den == v.fps_den && video->output_width == v.output_width && + video->output_height == v.output_height) { + blog(LOG_INFO, "applyResults: ctx=%p no change, skipping obs_set_video_info", video); + vd.skipped = true; + vd.obsSetVideoInfoRet = OBS_VIDEO_SUCCESS; + } else { + int ret = obs_set_video_info(video, &v); + vd.obsSetVideoInfoRet = ret; + blog(ret == OBS_VIDEO_SUCCESS ? LOG_INFO : LOG_WARNING, "applyResults: ctx=%p obs_set_video_info returned %d", video, ret); + if (ret != OBS_VIDEO_SUCCESS) { + std::lock_guard lock(eventsMutex); + events.push(AutoConfigInfo("error", "video_failed_ret_" + std::to_string(ret), 0)); + } + } - if (!customServer) - obs_data_set_string(settings, "service", serviceName.c_str()); - obs_data_set_string(settings, "server", server.c_str()); - obs_data_set_string(settings, "key", key.c_str()); + runContext.videoDecisions.push_back(vd); + + // Emit video_decision event for the new POC UI. + std::ostringstream ptrOss; + ptrOss << "0x" << std::hex << reinterpret_cast(video); + std::string ptrStr = ptrOss.str(); + + obs_data_t *p = obs_data_create(); + obs_data_set_string(p, "contextPtr", ptrStr.c_str()); + obs_data_t *before = obs_data_create(); + obs_data_set_int(before, "cx", vd.cxBefore); + obs_data_set_int(before, "cy", vd.cyBefore); + obs_data_set_int(before, "fpsNum", vd.fpsNumBefore); + obs_data_set_int(before, "fpsDen", vd.fpsDenBefore); + obs_data_set_obj(p, "before", before); + obs_data_release(before); + obs_data_t *after = obs_data_create(); + obs_data_set_int(after, "cx", vd.cxAfter); + obs_data_set_int(after, "cy", vd.cyAfter); + obs_data_set_int(after, "fpsNum", vd.fpsNumAfter); + obs_data_set_int(after, "fpsDen", vd.fpsDenAfter); + obs_data_set_obj(p, "after", after); + obs_data_release(after); + obs_data_set_int(p, "ret", vd.obsSetVideoInfoRet); + obs_data_set_bool(p, "skipped", vd.skipped); + std::string payload = obs_data_get_json(p); + obs_data_release(p); + + std::lock_guard lock(eventsMutex); + events.push(AutoConfigInfo("video_decision", "ctx_" + ptrStr, 100, payload)); + } - OBSService newService = obs_service_create(service_id, "default_service", settings, hotkeyData); + // 2. Per-target service URL + bitrate. Each target gets its own per-target + // result when the bandwidth test ran; otherwise falls back to the global values. + for (auto &st : streamingTargets) { + uint64_t targetBitrate = runContext.idealBitrate; + std::string targetServer = runContext.server; + for (auto &tr : runContext.targetResults) { + if (tr.streamingId == st.id) { + targetBitrate = tr.idealBitrate; + targetServer = tr.server; + break; + } + } - if (!newService) - return; + // Service URL + if (st.streaming->service && !targetServer.empty()) { + obs_data_t *settings = obs_data_create(); + obs_data_set_string(settings, "server", targetServer.c_str()); + obs_service_update(st.streaming->service, settings); + obs_data_release(settings); + blog(LOG_INFO, "applyResults: target %llu: applied service server '%s'", st.id, targetServer.c_str()); + } + + // Streaming bitrate selection (Option 2 — heuristic as floor, not ceiling): + // 1. Read user's current bitrate (CleanTestMode restored it before this). + // 2. Compute OBS quality heuristic for the chosen res/FPS. + // 3. Pick max(user, heuristic) — respect user when above the heuristic; + // otherwise use the heuristic as the recommendation. + // 4. Cap by what the bandwidth test actually delivered (targetBitrate). + // 5. Cap by the per-platform service cap (Twitch etc.). + if (st.streaming->videoEncoder && targetBitrate > 0) { + int userBitrate = 0; + { + obs_data_t *s = obs_encoder_get_settings(st.streaming->videoEncoder); + userBitrate = (int)obs_data_get_int(s, "bitrate"); + obs_data_release(s); + } - OBS_service::setService(newService, StreamServiceId::Main); - OBS_service::saveService(); + long double upperBitrate_d = EstimateUpperBitrate((int)runContext.idealResolutionCX, (int)runContext.idealResolutionCY, + runContext.idealFPSNum, runContext.idealFPSDen ? runContext.idealFPSDen : 1); + uint64_t heuristic = (uint64_t)(std::floor(upperBitrate_d / 50.0L) * 50.0L); + if (runContext.streamingEncoder != Encoder::x264 && heuristic > 0) { + heuristic = heuristic * 114ULL / 100ULL; + } - /* ---------------------------------- */ - /* save stream settings */ - config_set_int(ConfigManager::getInstance().getBasic(), "SimpleOutput", "VBitrate", idealBitrate); - config_set_string(ConfigManager::getInstance().getBasic(), "SimpleOutput", "StreamEncoder", GetEncoderId(streamingEncoder)); - config_remove_value(ConfigManager::getInstance().getBasic(), "SimpleOutput", "UseAdvanced"); + uint64_t choseBeforeCaps = ((uint64_t)userBitrate > heuristic) ? (uint64_t)userBitrate : heuristic; + uint64_t afterMeasuredCap = choseBeforeCaps; + if (afterMeasuredCap > targetBitrate) + afterMeasuredCap = targetBitrate; + + uint64_t afterPlatformCap = afterMeasuredCap; + if (st.streaming->service) { + obs_data_t *capSettings = obs_data_create(); + obs_data_set_int(capSettings, "bitrate", (long long)afterMeasuredCap); + obs_service_apply_encoder_settings(st.streaming->service, capSettings, nullptr); + uint64_t platformReturned = (uint64_t)obs_data_get_int(capSettings, "bitrate"); + if (platformReturned > 0 && platformReturned < afterMeasuredCap) + afterPlatformCap = platformReturned; + obs_data_release(capSettings); + } - config_save_safe(ConfigManager::getInstance().getBasic(), "tmp", nullptr); + uint64_t finalBitrate = afterPlatformCap; + + // Determine which cap was binding (in increasing-binding order). + std::string bindingCap; + if (afterPlatformCap < afterMeasuredCap) + bindingCap = "platform"; + else if (afterMeasuredCap < choseBeforeCaps) + bindingCap = "measured"; + else if ((uint64_t)userBitrate > heuristic) + bindingCap = "user"; + else + bindingCap = "heuristic"; + + blog(LOG_INFO, + "applyResults: target %llu picked %llu (user=%d heuristic=%llu choseBeforeCaps=%llu measured=%llu afterPlatform=%llu binding=%s)", + st.id, finalBitrate, userBitrate, heuristic, choseBeforeCaps, targetBitrate, afterPlatformCap, bindingCap.c_str()); + + obs_data_t *encSettings = obs_data_create(); + obs_data_set_int(encSettings, "bitrate", (long long)finalBitrate); + obs_encoder_update(st.streaming->videoEncoder, encSettings); + obs_data_release(encSettings); + blog(LOG_INFO, "applyResults: target %llu: applied video encoder bitrate %llu", st.id, finalBitrate); + + // Encoder id snapshot for the selection record + change log. + std::string currentEncoderId, chosenEncoderId; + bool encoderChanged = false; + if (st.streaming->videoEncoder) { + const char *cur = obs_encoder_get_id(st.streaming->videoEncoder); + const char *cho = GetEncoderId(runContext.streamingEncoder); + if (cur) + currentEncoderId = cur; + if (cho) + chosenEncoderId = cho; + if (cur && cho && strcmp(cur, cho) != 0) { + encoderChanged = true; + blog(LOG_INFO, "applyResults: target %llu: chosen encoder '%s' differs from current '%s'", st.id, cho, cur); + } + } - eventsMutex.lock(); + AutoconfigRun::SelectionDetail sd; + sd.targetId = st.id; + sd.userBitrate = userBitrate; + sd.heuristic = heuristic; + sd.choseBeforeCaps = choseBeforeCaps; + sd.afterMeasuredCap = afterMeasuredCap; + sd.afterPlatformCap = afterPlatformCap; + sd.picked = finalBitrate; + sd.bindingCap = bindingCap; + sd.appliedServer = targetServer; + sd.currentEncoderId = currentEncoderId; + sd.chosenEncoderId = chosenEncoderId; + sd.encoderChanged = encoderChanged; + runContext.selectionDetails.push_back(sd); + + obs_data_t *p = obs_data_create(); + obs_data_set_int(p, "targetId", (long long)sd.targetId); + obs_data_set_int(p, "userBitrate", sd.userBitrate); + obs_data_set_int(p, "heuristic", (long long)sd.heuristic); + obs_data_set_int(p, "choseBeforeCaps", (long long)sd.choseBeforeCaps); + obs_data_set_int(p, "afterMeasuredCap", (long long)sd.afterMeasuredCap); + obs_data_set_int(p, "afterPlatformCap", (long long)sd.afterPlatformCap); + obs_data_set_int(p, "picked", (long long)sd.picked); + obs_data_set_string(p, "bindingCap", sd.bindingCap.c_str()); + obs_data_set_string(p, "appliedServer", sd.appliedServer.c_str()); + obs_data_set_string(p, "currentEncoderId", sd.currentEncoderId.c_str()); + obs_data_set_string(p, "chosenEncoderId", sd.chosenEncoderId.c_str()); + obs_data_set_bool(p, "encoderChanged", sd.encoderChanged); + std::string payload = obs_data_get_json(p); + obs_data_release(p); + + std::lock_guard lock(eventsMutex); + events.push(AutoConfigInfo("selection_decision", "target_" + std::to_string(sd.targetId), 100, payload)); + } + } + + // 3. Recording bitrate — discover all recording targets, apply the global + // idealBitrate (minimum across all streaming targets) to each. + if (runContext.idealBitrate > 0) { + osn::IRecording::Manager::GetInstance().for_each([&](osn::FileOutput *fileOutput) { + auto *recording = static_cast(fileOutput); + if (recording && recording->videoEncoder) { + obs_data_t *encSettings = obs_data_create(); + obs_data_set_int(encSettings, "bitrate", (long long)runContext.idealBitrate); + obs_encoder_update(recording->videoEncoder, encSettings); + obs_data_release(encSettings); + blog(LOG_INFO, "applyResults: applied recording video encoder bitrate %llu", runContext.idealBitrate); + } + }); + } +} + +void autoConfig::SaveStreamSettings() +{ + // Legacy IPC stage. The actual apply happens in SaveSettings (the terminal + // stage in the legacy frontend contract). Kept here as a no-op so callers that + // invoke both don't error. + std::lock_guard lock(eventsMutex); events.push(AutoConfigInfo("stopping_step", "saving_service", 100)); - eventsMutex.unlock(); } void autoConfig::SaveSettings() { - eventsMutex.lock(); - events.push(AutoConfigInfo("starting_step", "saving_settings", 0)); - eventsMutex.unlock(); - - if (recordingEncoder != Encoder::Stream) - config_set_string(ConfigManager::getInstance().getBasic(), "SimpleOutput", "RecEncoder", GetEncoderId(recordingEncoder)); - - const char *quality = recordingQuality == Quality::High ? "Small" : "Stream"; + { + std::lock_guard lock(eventsMutex); + events.push(AutoConfigInfo("starting_step", "applying_settings", 0)); + } - config_set_string(ConfigManager::getInstance().getBasic(), "Output", "Mode", "Simple"); - config_set_string(ConfigManager::getInstance().getBasic(), "SimpleOutput", "RecQuality", quality); - config_set_int(ConfigManager::getInstance().getBasic(), "Video", "OutputCX", idealResolutionCX); - config_set_int(ConfigManager::getInstance().getBasic(), "Video", "OutputCY", idealResolutionCY); - config_set_int(ConfigManager::getInstance().getBasic(), "Video", "Canvases", 1); + applyResults(); - config_set_bool(ConfigManager::getInstance().getBasic(), "Output", "DynamicBitrate", false); + runContext.runComplete = true; - if (fpsType != FPSType::UseCurrent) { - config_set_uint(ConfigManager::getInstance().getBasic(), "Video", "FPSType", 0); - config_set_string(ConfigManager::getInstance().getBasic(), "Video", "FPSCommon", std::to_string(idealFPSNum).c_str()); + { + std::lock_guard lock(eventsMutex); + events.push(AutoConfigInfo("stopping_step", "applying_settings", 100)); + events.push(AutoConfigInfo("done", "", 100)); } - - config_save_safe(ConfigManager::getInstance().getBasic(), "tmp", nullptr); - - eventsMutex.lock(); - events.push(AutoConfigInfo("stopping_step", "saving_settings", 100)); - events.push(AutoConfigInfo("done", "", 0)); - eventsMutex.unlock(); } diff --git a/obs-studio-server/source/nodeobs_autoconfig.h b/obs-studio-server/source/nodeobs_autoconfig.h index c42a7ce93..b82f60554 100644 --- a/obs-studio-server/source/nodeobs_autoconfig.h +++ b/obs-studio-server/source/nodeobs_autoconfig.h @@ -26,7 +26,6 @@ #include #include #include "nodeobs_api.h" -#include "nodeobs_service.h" namespace autoConfig { void Register(ipc::server &srv); @@ -40,6 +39,7 @@ void StartSaveStreamSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval); void TerminateAutoConfig(void *data, const int64_t id, const std::vector &args, std::vector &rval); void Query(void *data, const int64_t id, const std::vector &args, std::vector &rval); +void GetAutoConfigSummary(void *data, const int64_t id, const std::vector &args, std::vector &rval); void StopThread(); void FindIdealHardwareResolution(); @@ -54,4 +54,6 @@ void SetDefaultSettings(); void TestHardwareEncoding(); bool CanTestServer(const char *server); void WaitPendingTests(double timeout = 10); -} // namespace autoConfig \ No newline at end of file +int GetStartingBitrate(const std::string &serviceName); +void TestBandwidthThreadV2(void); +} // namespace autoConfig diff --git a/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.cpp b/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.cpp new file mode 100644 index 000000000..93b95528e --- /dev/null +++ b/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.cpp @@ -0,0 +1,212 @@ +/****************************************************************************** + Copyright (C) 2016-2019 by Streamlabs (General Workings Inc) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +******************************************************************************/ + +#include "nodeobs_autoconfig_resource_sampler.h" + +#include +#include + +#include +#include + +namespace autoConfig { + +namespace { +constexpr uint64_t kMiB = 1024ULL * 1024ULL; +} + +ResourceSampler::ResourceSampler() +{ + cpuInfo_ = os_cpu_usage_info_start(); + +#ifdef _WIN32 + // Pick the discrete GPU when present by iterating adapters and choosing the + // one with the largest dedicated VRAM. EnumAdapterByGpuPreference would be + // cleaner but lives on IDXGIFactory6 (dxgi1_6.h); the manual scan avoids + // the SDK version dependency. + Microsoft::WRL::ComPtr factory; + if (FAILED(CreateDXGIFactory1(IID_PPV_ARGS(&factory)))) + return; + + Microsoft::WRL::ComPtr best; + SIZE_T bestVram = 0; + for (UINT i = 0;; ++i) { + Microsoft::WRL::ComPtr adapter; + if (factory->EnumAdapters1(i, &adapter) == DXGI_ERROR_NOT_FOUND) + break; + DXGI_ADAPTER_DESC1 desc{}; + if (FAILED(adapter->GetDesc1(&desc))) + continue; + if (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) + continue; + if (desc.DedicatedVideoMemory > bestVram) { + bestVram = desc.DedicatedVideoMemory; + best = adapter; + } + } + if (!best) + return; + + Microsoft::WRL::ComPtr adapter3; + if (FAILED(best.As(&adapter3))) + return; + dxgiAdapter_ = adapter3; + gpuAvailable_ = true; +#endif +} + +ResourceSampler::~ResourceSampler() +{ + if (worker_.joinable()) { + workerStop_.store(true, std::memory_order_relaxed); + worker_.join(); + } + if (cpuInfo_) { + os_cpu_usage_info_destroy(cpuInfo_); + cpuInfo_ = nullptr; + } +} + +void ResourceSampler::start(const std::string &phase, std::chrono::milliseconds interval) +{ + phase_ = phase; + startTime_ = std::chrono::steady_clock::now(); + { + std::lock_guard lk(aggMutex_); + samples_.clear(); + // Pre-reserve enough for typical phase durations (5s bandwidth + buffer) + // at the 250ms cadence we use for background sampling. + samples_.reserve(32); + } + started_ = true; + + // First query after start returns no useful delta — discard it so the + // first real sample() call is meaningful. + if (cpuInfo_) + (void)os_cpu_usage_info_query(cpuInfo_); + + if (interval.count() > 0) { + // Take one sample synchronously before spawning the worker so a phase + // that completes faster than the worker's first scheduling slice still + // produces at least one data point. + sample(); + workerStop_.store(false, std::memory_order_relaxed); + worker_ = std::thread(&ResourceSampler::workerLoop, this, interval); + } +} + +void ResourceSampler::sample() +{ + if (!started_) + return; + + ResourceSample s; + // os_cpu_usage_info_query returns NaN when called too soon after start() + // (zero time delta) — treat that as 0% so downstream JSON stays numeric. + double cpu = cpuInfo_ ? os_cpu_usage_info_query(cpuInfo_) : 0.0; + s.cpuPct = (std::isnan(cpu) || std::isinf(cpu) || cpu < 0.0) ? 0.0 : cpu; + s.procRamMB = static_cast(os_get_proc_resident_size()) / static_cast(kMiB); + +#ifdef _WIN32 + if (dxgiAdapter_) { + DXGI_QUERY_VIDEO_MEMORY_INFO info{}; + if (SUCCEEDED(dxgiAdapter_->QueryVideoMemoryInfo(0, DXGI_MEMORY_SEGMENT_GROUP_LOCAL, &info))) { + s.gpuVramUsedMB = info.CurrentUsage / kMiB; + s.gpuVramBudgetMB = info.Budget / kMiB; + } + } +#endif + + std::lock_guard lk(aggMutex_); + samples_.push_back(s); +} + +void ResourceSampler::workerLoop(std::chrono::milliseconds interval) +{ + while (!workerStop_.load(std::memory_order_relaxed)) { + sample(); + // Sleep in small slices so stop() returns promptly when the test ends. + auto end = std::chrono::steady_clock::now() + interval; + while (std::chrono::steady_clock::now() < end) { + if (workerStop_.load(std::memory_order_relaxed)) + return; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } +} + +// Nearest-rank percentile on a sorted series. p is in [0, 100]. Returns the +// element at position ceil(p/100 * N) - 1, clamped — so p95 of 20 samples +// returns the 19th-of-20 sample, dropping a single top outlier. +template static T percentileNearestRank(std::vector sorted, double p) +{ + if (sorted.empty()) + return T{}; + std::sort(sorted.begin(), sorted.end()); + size_t n = sorted.size(); + double rank = (p / 100.0) * static_cast(n); + size_t idx = rank <= 0.0 ? 0 : static_cast(std::ceil(rank)) - 1; + if (idx >= n) + idx = n - 1; + return sorted[idx]; +} + +ResourceWindow ResourceSampler::stop() +{ + if (worker_.joinable()) { + workerStop_.store(true, std::memory_order_relaxed); + worker_.join(); + } + + ResourceWindow w; + w.phase = phase_; + w.durationMs = static_cast(std::chrono::duration_cast(std::chrono::steady_clock::now() - startTime_).count()); + w.gpuAvailable = gpuAvailable_; + + std::lock_guard lk(aggMutex_); + w.sampleCount = static_cast(samples_.size()); + if (!samples_.empty()) { + // Sort each component independently — CPU's p95 sample is rarely the + // same physical sample as RAM's p95. + std::vector cpu, ram; + std::vector gpuUsed, gpuBudget; + cpu.reserve(samples_.size()); + ram.reserve(samples_.size()); + gpuUsed.reserve(samples_.size()); + gpuBudget.reserve(samples_.size()); + for (auto &s : samples_) { + cpu.push_back(s.cpuPct); + ram.push_back(s.procRamMB); + gpuUsed.push_back(s.gpuVramUsedMB); + gpuBudget.push_back(s.gpuVramBudgetMB); + } + w.p50Sample.cpuPct = percentileNearestRank(cpu, 50.0); + w.p50Sample.procRamMB = percentileNearestRank(ram, 50.0); + w.p50Sample.gpuVramUsedMB = percentileNearestRank(gpuUsed, 50.0); + w.p50Sample.gpuVramBudgetMB = percentileNearestRank(gpuBudget, 50.0); + w.p95Sample.cpuPct = percentileNearestRank(cpu, 95.0); + w.p95Sample.procRamMB = percentileNearestRank(ram, 95.0); + w.p95Sample.gpuVramUsedMB = percentileNearestRank(gpuUsed, 95.0); + w.p95Sample.gpuVramBudgetMB = percentileNearestRank(gpuBudget, 95.0); + } + + started_ = false; + return w; +} + +} // namespace autoConfig diff --git a/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.h b/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.h new file mode 100644 index 000000000..82c2f1dba --- /dev/null +++ b/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.h @@ -0,0 +1,112 @@ +/****************************************************************************** + Copyright (C) 2016-2019 by Streamlabs (General Workings Inc) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +******************************************************************************/ + +#pragma once +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include +#endif + +struct os_cpu_usage_info; + +namespace autoConfig { + +// Single resource snapshot. cpuPct is the per-process CPU% reported by libOBS's +// os_cpu_usage_info; procRamMB is the resident set size in MiB. GPU VRAM fields +// are populated only on Windows when DXGI bring-up succeeded. +struct ResourceSample { + double cpuPct = 0.0; + double procRamMB = 0.0; + uint64_t gpuVramUsedMB = 0; + uint64_t gpuVramBudgetMB = 0; +}; + +// Aggregated samples for one autoconfig phase. Each component is sorted +// independently across the window and reduced to two percentiles: +// p50 — typical value during the test +// p95 — sustained ceiling, ignoring single-sample spikes from unrelated +// OS noise (e.g. a background process briefly using CPU) +// We deliberately do NOT surface min/max/avg: max is dominated by one-off +// spikes that aren't caused by autoconfig, and avg is hard to act on. +struct ResourceWindow { + std::string phase; + int sampleCount = 0; + int durationMs = 0; + ResourceSample p50Sample; + ResourceSample p95Sample; + bool gpuAvailable = false; +}; + +// Sampler owns one libOBS CPU info handle and (Windows only) one DXGI adapter +// reference. Two usage modes: +// +// 1. Manual: start(phase); sample(); ... ; stop(); +// 2. Background: start(phase, interval); [worker samples periodically]; stop(); +// +// In background mode start() spawns a thread that calls sample() every `interval` +// until stop() joins it. The bandwidth test uses mode 1 because it already has a +// 250ms wait loop; the encoder tests use mode 2 because their wait happens deep +// inside helpers we don't want to instrument. +// +// Don't share an instance across threads — sample() is not internally synchronized +// against external callers (the worker thread is the only sampler in mode 2). +class ResourceSampler { +public: + ResourceSampler(); + ~ResourceSampler(); + + ResourceSampler(const ResourceSampler &) = delete; + ResourceSampler &operator=(const ResourceSampler &) = delete; + + void start(const std::string &phase, std::chrono::milliseconds interval = std::chrono::milliseconds(0)); + void sample(); + ResourceWindow stop(); + + bool gpuAvailable() const { return gpuAvailable_; } + +private: + void workerLoop(std::chrono::milliseconds interval); + + std::string phase_; + std::chrono::steady_clock::time_point startTime_; + + std::mutex aggMutex_; + std::vector samples_; + + os_cpu_usage_info *cpuInfo_ = nullptr; + bool started_ = false; + + std::thread worker_; + std::atomic workerStop_{false}; + + bool gpuAvailable_ = false; +#ifdef _WIN32 + Microsoft::WRL::ComPtr dxgiAdapter_; +#endif +}; + +} // namespace autoConfig diff --git a/obs-studio-server/source/osn-advanced-replay-buffer.cpp b/obs-studio-server/source/osn-advanced-replay-buffer.cpp index 8a49f8cf3..9c2d329a0 100644 --- a/obs-studio-server/source/osn-advanced-replay-buffer.cpp +++ b/obs-studio-server/source/osn-advanced-replay-buffer.cpp @@ -22,6 +22,7 @@ #include "shared.hpp" #include "osn-audio-track.hpp" #include "osn-encoders.hpp" +#include void osn::IAdvancedReplayBuffer::Register(ipc::server &srv) { diff --git a/obs-studio-server/source/osn-advanced-streaming.cpp b/obs-studio-server/source/osn-advanced-streaming.cpp index 021d254d9..fbdc5633b 100644 --- a/obs-studio-server/source/osn-advanced-streaming.cpp +++ b/obs-studio-server/source/osn-advanced-streaming.cpp @@ -24,6 +24,7 @@ #include "nodeobs_audio_encoders.h" #include "osn-audio-track.hpp" #include "osn-encoders.hpp" +#include "osn-streaming-helpers.hpp" void osn::IAdvancedStreaming::Register(ipc::server &srv) { @@ -393,6 +394,43 @@ osn::AdvancedStreaming::~AdvancedStreaming() } } +void osn::AdvancedStreaming::checkOutput() +{ + const char *type = osn::streaming_helpers::getStreamOutputType(service); + if (!type) + type = "rtmp_output"; + + if (!GetOutput() || strcmp(obs_output_get_id(GetOutput()), type) != 0) + CreateOutput(type, "stream"); +} + +void osn::AdvancedStreaming::start() +{ + UpdateEncoders(); + + if (!setAudioEncoder(this)) + return; + + if (rescaling) + obs_encoder_set_scaled_size(videoEncoder, outputWidth, outputHeight); + + obs_output_set_video_encoder(GetOutput(), videoEncoder); + + if (enableTwitchVOD) { + twitchVODSupported = isTwitchVODSupported(); + if (twitchVODSupported) + SetupTwitchSoundtrackAudio(this); + } + + obs_output_set_service(GetOutput(), service); + + std::string outputSettingsError; + if (!ApplyOutputSettings(GetOutput(), outputSettingsError)) + return; + + StartOutput(); +} + void osn::IAdvancedStreaming::Start(void *data, const int64_t id, const std::vector &args, std::vector &rval) { AdvancedStreaming *streaming = static_cast(osn::IAdvancedStreaming::Manager::GetInstance().find(args[0].value_union.ui64)); @@ -419,7 +457,7 @@ void osn::IAdvancedStreaming::Start(void *data, const int64_t id, const std::vec streaming->UpdateEncoders(); - const char *type = OBS_service::getStreamOutputType(streaming->service); + const char *type = osn::streaming_helpers::getStreamOutputType(streaming->service); if (!type) type = "rtmp_output"; diff --git a/obs-studio-server/source/osn-advanced-streaming.hpp b/obs-studio-server/source/osn-advanced-streaming.hpp index c8ca0442f..e94215a9f 100644 --- a/obs-studio-server/source/osn-advanced-streaming.hpp +++ b/obs-studio-server/source/osn-advanced-streaming.hpp @@ -53,6 +53,8 @@ class AdvancedStreaming : public Streaming { uint32_t outputHeight; void UpdateEncoders(); + void start() override; + void checkOutput() override; }; class IAdvancedStreaming : public IStreaming { diff --git a/obs-studio-server/source/osn-simple-recording.cpp b/obs-studio-server/source/osn-simple-recording.cpp index 2a872d376..996d48a33 100644 --- a/obs-studio-server/source/osn-simple-recording.cpp +++ b/obs-studio-server/source/osn-simple-recording.cpp @@ -24,6 +24,7 @@ #include "nodeobs_audio_encoders.h" #include "osn-file-output.hpp" #include "osn-encoders.hpp" +#include "osn-streaming-helpers.hpp" void osn::ISimpleRecording::Register(ipc::server &srv) { @@ -314,7 +315,7 @@ void osn::SimpleRecording::UpdateEncoders() case RecQuality::Stream: { if (!streaming) return; - streaming->UpdateEncoders(); + streaming->updateEncoders(); videoEncoder = streaming->videoEncoder; audioEncoder = streaming->audioEncoder; if (obs_get_multiple_rendering()) { diff --git a/obs-studio-server/source/osn-simple-replay-buffer.cpp b/obs-studio-server/source/osn-simple-replay-buffer.cpp index 3d18fa382..faed6df88 100644 --- a/obs-studio-server/source/osn-simple-replay-buffer.cpp +++ b/obs-studio-server/source/osn-simple-replay-buffer.cpp @@ -22,6 +22,7 @@ #include "shared.hpp" #include "nodeobs_audio_encoders.h" #include "osn-encoders.hpp" +#include void osn::ISimpleReplayBuffer::Register(ipc::server &srv) { @@ -78,15 +79,15 @@ void osn::ISimpleReplayBuffer::Destroy(void *data, const int64_t id, const std:: static void remove_reserved_file_characters(std::string &s) { - replace(s.begin(), s.end(), '/', '_'); - replace(s.begin(), s.end(), '\\', '_'); - replace(s.begin(), s.end(), '*', '_'); - replace(s.begin(), s.end(), '?', '_'); - replace(s.begin(), s.end(), '"', '_'); - replace(s.begin(), s.end(), '|', '_'); - replace(s.begin(), s.end(), ':', '_'); - replace(s.begin(), s.end(), '>', '_'); - replace(s.begin(), s.end(), '<', '_'); + std::replace(s.begin(), s.end(), '/', '_'); + std::replace(s.begin(), s.end(), '\\', '_'); + std::replace(s.begin(), s.end(), '*', '_'); + std::replace(s.begin(), s.end(), '?', '_'); + std::replace(s.begin(), s.end(), '"', '_'); + std::replace(s.begin(), s.end(), '|', '_'); + std::replace(s.begin(), s.end(), ':', '_'); + std::replace(s.begin(), s.end(), '>', '_'); + std::replace(s.begin(), s.end(), '<', '_'); } void osn::ISimpleReplayBuffer::Start(void *data, const int64_t id, const std::vector &args, std::vector &rval) @@ -105,7 +106,7 @@ void osn::ISimpleReplayBuffer::Start(void *data, const int64_t id, const std::ve if (obs_get_multiple_rendering() && replayBuffer->usesStream) { if (!replayBuffer->streaming) return; - replayBuffer->streaming->UpdateEncoders(); + replayBuffer->streaming->updateEncoders(); audioEncoder = replayBuffer->streaming->audioEncoder; videoEncoder = replayBuffer->streaming->videoEncoder; } else { diff --git a/obs-studio-server/source/osn-simple-streaming.cpp b/obs-studio-server/source/osn-simple-streaming.cpp index 33f8ffbf6..c7957fc20 100644 --- a/obs-studio-server/source/osn-simple-streaming.cpp +++ b/obs-studio-server/source/osn-simple-streaming.cpp @@ -23,6 +23,7 @@ #include "shared.hpp" #include "nodeobs_audio_encoders.h" #include "osn-encoders.hpp" +#include "osn-streaming-helpers.hpp" void osn::ISimpleStreaming::Register(ipc::server &srv) { @@ -262,7 +263,7 @@ void UpdateStreamingSettings_amd(obs_data_t *settings, int bitrate) obs_data_set_int(settings, "bf", 3); } -void osn::SimpleStreaming::UpdateEncoders() +void osn::SimpleStreaming::updateEncoders() { if (!videoEncoder || !audioEncoder) return; @@ -325,6 +326,44 @@ void osn::SimpleStreaming::UpdateEncoders() obs_data_release(audioEncSettings); } +void osn::SimpleStreaming::start() +{ + updateEncoders(); + obs_encoder_set_audio(audioEncoder, obs_get_audio()); + obs_output_set_audio_encoder(GetOutput(), audioEncoder, 0); + obs_encoder_set_video_mix(audioEncoder, obs_video_mix_get(GetCanvas(), OBS_STREAMING_VIDEO_RENDERING)); + + obs_output_set_video_encoder(GetOutput(), videoEncoder); + + if (enableTwitchVOD) { + twitchVODSupported = isTwitchVODSupported(); + if (twitchVODSupported) + SetupTwitchSoundtrackAudio(this); + } + + obs_output_set_service(GetOutput(), service); + + std::string outputSettingsError; + if (!ApplyOutputSettings(GetOutput(), outputSettingsError)) { + blog(LOG_ERROR, "Failed to apply streaming output settings: %s", outputSettingsError.c_str()); + return; + } + + blog(LOG_INFO, "Start Streaming using %s encoder.", obs_encoder_get_id(videoEncoder)); + + StartOutput(); +} + +void osn::SimpleStreaming::checkOutput() +{ + const char *type = osn::streaming_helpers::getStreamOutputType(service); + if (!type) + type = "rtmp_output"; + + if (!GetOutput() || strcmp(obs_output_get_id(GetOutput()), type) != 0) + CreateOutput(type, "stream"); +} + void osn::ISimpleStreaming::Start(void *data, const int64_t id, const std::vector &args, std::vector &rval) { SimpleStreaming *streaming = static_cast(osn::ISimpleStreaming::Manager::GetInstance().find(args[0].value_union.ui64)); @@ -332,16 +371,22 @@ void osn::ISimpleStreaming::Start(void *data, const int64_t id, const std::vecto PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, "Simple streaming reference is not valid."); } + // Refuse a normal Start() while the streaming object is in autoconfig test mode — + // the test owns the output until CleanTestMode() runs. Note this is the only + // guard on regular Start; the testMode flag is cleared in CleanTestMode. + if (streaming->testMode) { + PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, "Service in test mode."); + } + if (!streaming->service) { PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, "Invalid service."); } - const char *type = OBS_service::getStreamOutputType(streaming->service); - if (!type) - type = "rtmp_output"; + if (!streaming->GetCanvas()) { + PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, "Invalid main canvas."); + } - if (!streaming->GetOutput() || strcmp(obs_output_get_id(streaming->GetOutput()), type) != 0) - streaming->CreateOutput(type, "stream"); + streaming->checkOutput(); if (!streaming->GetOutput()) { PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, "Error while creating the streaming output."); @@ -355,10 +400,6 @@ void osn::ISimpleStreaming::Start(void *data, const int64_t id, const std::vecto PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, "Invalid audio encoder."); } - if (!streaming->GetCanvas()) { - PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, "Invalid main canvas."); - } - if (!streaming->GetCanvasVideo(obs_get_multiple_rendering() ? OBS_STREAMING_VIDEO_RENDERING : OBS_MAIN_VIDEO_RENDERING)) { PRETTY_ERROR_RETURN(ErrorCode::CriticalError, "Video pipeline not initialized (canvas has no video mix). " "Graphics device may have been lost during startup. Restart the app."); @@ -370,29 +411,19 @@ void osn::ISimpleStreaming::Start(void *data, const int64_t id, const std::vecto PRETTY_ERROR_RETURN(ErrorCode::CriticalError, "The provided encoder is not valid for the current service."); } - streaming->UpdateEncoders(); - obs_encoder_set_audio(streaming->audioEncoder, obs_get_audio()); - obs_output_set_audio_encoder(streaming->GetOutput(), streaming->audioEncoder, 0); - obs_encoder_set_video_mix(streaming->audioEncoder, obs_video_mix_get(streaming->GetCanvas(), OBS_STREAMING_VIDEO_RENDERING)); - - obs_output_set_video_encoder(streaming->GetOutput(), streaming->videoEncoder); - - if (streaming->enableTwitchVOD) { - streaming->twitchVODSupported = streaming->isTwitchVODSupported(); - if (streaming->twitchVODSupported) - SetupTwitchSoundtrackAudio(streaming); + if (!streaming->delay) { + PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, "Invalid delay."); } - obs_output_set_service(streaming->GetOutput(), streaming->service); - - std::string outputSettingsError; - if (!streaming->ApplyOutputSettings(streaming->GetOutput(), outputSettingsError)) { - PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, outputSettingsError.c_str()); + if (!streaming->reconnect) { + PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, "Invalid reconnect."); } - blog(LOG_INFO, "Start Streaming using %s encoder.", obs_encoder_get_id(streaming->videoEncoder)); + if (!streaming->network) { + PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, "Invalid network."); + } - streaming->StartOutput(); + streaming->start(); rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); AUTO_DEBUG; diff --git a/obs-studio-server/source/osn-simple-streaming.hpp b/obs-studio-server/source/osn-simple-streaming.hpp index b84a7cd44..0be21ce60 100644 --- a/obs-studio-server/source/osn-simple-streaming.hpp +++ b/obs-studio-server/source/osn-simple-streaming.hpp @@ -41,7 +41,9 @@ class SimpleStreaming : public Streaming { bool useAdvanced; std::string customEncSettings; - void UpdateEncoders(); + void updateEncoders(); + void start() override; + void checkOutput() override; }; class ISimpleStreaming : public IStreaming { diff --git a/obs-studio-server/source/osn-streaming-helpers.hpp b/obs-studio-server/source/osn-streaming-helpers.hpp new file mode 100644 index 000000000..a0ce0b71e --- /dev/null +++ b/obs-studio-server/source/osn-streaming-helpers.hpp @@ -0,0 +1,105 @@ +/****************************************************************************** + Copyright (C) 2016-2022 by Streamlabs (General Workings Inc) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +******************************************************************************/ + +#pragma once +#include + +// Encoder defines - centralized to decouple from old API (nodeobs_service.h) +// These are used across multiple streaming/recording/encoder files +#ifdef WIN32 +#define SIMPLE_ENCODER_X264 "x264" +#elif __APPLE__ +#define SIMPLE_ENCODER_X264 "obs_x264" +#endif +#ifndef SIMPLE_ENCODER_X264 +#define SIMPLE_ENCODER_X264 "x264" +#endif +#define SIMPLE_ENCODER_X264_LOWCPU "x264_lowcpu" +#define SIMPLE_ENCODER_QSV "qsv" +#define SIMPLE_ENCODER_QSV_AV1 "qsv_av1" +#define SIMPLE_ENCODER_NVENC "nvenc" +#define SIMPLE_ENCODER_NVENC_AV1 "nvenc_av1" +#define SIMPLE_ENCODER_NVENC_HEVC "nvenc_hevc" +#define SIMPLE_ENCODER_AMD "amd" +#define SIMPLE_ENCODER_AMD_HEVC "amd_hevc" +#define SIMPLE_ENCODER_AMD_AV1 "amd_av1" +#define SIMPLE_ENCODER_APPLE_H264 "apple_h264" +#define SIMPLE_ENCODER_APPLE_HEVC "apple_hevc" + +#define ADVANCED_ENCODER_X264 "obs_x264" +#define ADVANCED_ENCODER_QSV "obs_qsv11" +#define ADVANCED_ENCODER_NVENC "ffmpeg_nvenc" +#define ADVANCED_ENCODER_AMD "h264_texture_amf" +#define ADVANCED_ENCODER_AMD_HEVC "h265_texture_amf" + +#define ENCODER_NVENC_H264_TEX "obs_nvenc_h264_tex" +#define ENCODER_NVENC_HEVC_TEX "obs_nvenc_hevc_tex" +#define ENCODER_NVENC_AV1_TEX "obs_nvenc_av1_tex" + +// Deprecated encoders +#define ENCODER_JIM_NVENC "jim_nvenc" +#define ENCODER_JIM_HEVC_NVENC "jim_hevc_nvenc" +#define ENCODER_JIM_AV1_NVENC "jim_av1_nvenc" + +#define ENCODER_AV1_SVT_FFMPEG "ffmpeg_svt_av1" +#define ENCODER_AV1_AOM_FFMPEG "ffmpeg_aom_av1" + +#define APPLE_SOFTWARE_VIDEO_ENCODER "com.apple.videotoolbox.videoencoder.h264" +#define APPLE_HARDWARE_VIDEO_ENCODER "com.apple.videotoolbox.videoencoder.h264.gva" +#define APPLE_HARDWARE_VIDEO_ENCODER_M1 "com.apple.videotoolbox.videoencoder.ave.avc" + +namespace osn { +namespace streaming_helpers { + +// Helper function to get stream output type from service +// Replaces OBS_service::getStreamOutputType to decouple from old API +inline const char *getStreamOutputType(obs_service_t *service) +{ + if (!service) + return nullptr; + + const char *protocol = obs_service_get_protocol(service); + if (!protocol) { + blog(LOG_WARNING, "The service '%s' has no protocol set", obs_service_get_id(service)); + return nullptr; + } + + if (!obs_is_output_protocol_registered(protocol)) { + blog(LOG_WARNING, "The protocol '%s' is not registered", protocol); + return nullptr; + } + + // Check if the service has a preferred output type + const char *output = obs_service_get_preferred_output_type(service); + if (output && (obs_get_output_flags(output) & OBS_OUTPUT_SERVICE) != 0) + return output; + + // Prefer first-party output types based on protocol + if (strcmp(protocol, "RTMP") == 0 || strcmp(protocol, "RTMPS") == 0) + return "rtmp_output"; + else if (strcmp(protocol, "HLS") == 0) + return "ffmpeg_hls_muxer"; + else if (strcmp(protocol, "SRT") == 0 || strcmp(protocol, "RIST") == 0) + return "ffmpeg_mpegts_muxer"; + + // Default fallback + return nullptr; +} + +} // namespace streaming_helpers +} // namespace osn diff --git a/obs-studio-server/source/osn-streaming.cpp b/obs-studio-server/source/osn-streaming.cpp index b91ab21d5..0094a87d0 100644 --- a/obs-studio-server/source/osn-streaming.cpp +++ b/obs-studio-server/source/osn-streaming.cpp @@ -24,6 +24,8 @@ #include "osn-encoders.hpp" //os_gettime_ns #include +#include +#include osn::Streaming::~Streaming() { @@ -32,6 +34,96 @@ osn::Streaming::~Streaming() obs_encoder_release(streamArchive); streamArchive = nullptr; } + if (originalServiceSettings) { + obs_data_release(originalServiceSettings); + originalServiceSettings = nullptr; + } +} + +void osn::Streaming::testBandwidth(bool &gotError, int testBitrate) +{ + if (!service || !GetOutput()) { + gotError = true; + return; + } + + // Override the user's encoder bitrate with the ceiling-search target so the + // bandwidth measurement isn't capped by whatever the user happens to have + // set (often 2500). Restored in CleanTestMode(). + if (testBitrate > 0 && videoEncoder) { + obs_data_t *encSettings = obs_encoder_get_settings(videoEncoder); + originalEncoderBitrate = (int)obs_data_get_int(encSettings, "bitrate"); + obs_data_release(encSettings); + + obs_data_t *override = obs_data_create(); + obs_data_set_int(override, "bitrate", (long long)testBitrate); + obs_encoder_update(videoEncoder, override); + obs_data_release(override); + } + + if (originalServiceSettings) { + obs_data_release(originalServiceSettings); + } + originalServiceSettings = obs_service_get_settings(service); + obs_data_addref(originalServiceSettings); + + obs_data_t *serviceSettings = obs_data_create(); + obs_data_apply(serviceSettings, originalServiceSettings); + + const char *serviceName = obs_data_get_string(serviceSettings, "service"); + if (serviceName && strcmp(serviceName, "Twitch") == 0) { + std::string key = obs_service_get_connect_info(service, OBS_SERVICE_CONNECT_INFO_STREAM_KEY); + + while (!key.empty()) { + char ch = key.back(); + if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') + key.pop_back(); + else + break; + } + + key += "?bandwidthtest"; + obs_data_set_string(serviceSettings, "key", key.c_str()); + } + + obs_service_update(service, serviceSettings); + obs_data_release(serviceSettings); + + testMode = true; + start(); +} + +void osn::Streaming::CleanTestMode() +{ + if (GetOutput()) { + if (obs_output_active(GetOutput())) { + obs_output_stop(GetOutput()); + } + // obs_output_stop is asynchronous: the output stays active until its + // internal stop thread finishes flushing. Block here so callers + // (autoconfig SaveSettings -> applyResults -> obs_set_video_info) + // don't race with OBS_VIDEO_CURRENTLY_ACTIVE (-4). + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (obs_output_active(GetOutput()) && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + } + + if (service && originalServiceSettings) { + obs_service_update(service, originalServiceSettings); + obs_data_release(originalServiceSettings); + originalServiceSettings = nullptr; + } + + if (videoEncoder && originalEncoderBitrate > 0) { + obs_data_t *restore = obs_data_create(); + obs_data_set_int(restore, "bitrate", (long long)originalEncoderBitrate); + obs_encoder_update(videoEncoder, restore); + obs_data_release(restore); + originalEncoderBitrate = 0; + } + + testMode = false; } void osn::Streaming::DeleteOutput() @@ -342,6 +434,13 @@ void osn::IStreaming::Query(void *data, const int64_t id, const std::vectortestMode) { + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); + rval.push_back(ipc::value(true)); + AUTO_DEBUG; + return; + } + auto signalOpt = streaming->PopReceivedSignal(); if (!signalOpt.has_value()) { rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); @@ -423,6 +522,15 @@ void osn::Streaming::setNetworkLegacySettings() config_set_bool(ConfigManager::getInstance().getBasic(), "Output", "LowLatencyEnable", network->enableDynamicBitrate); } +std::string osn::Streaming::testQuery() +{ + auto signalOpt = PopReceivedSignal(); + if (!signalOpt.has_value()) { + return ""; + } + return signalOpt.value().signal; +} + void osn::IStreaming::GetDroppedFrames(void *data, const int64_t id, const std::vector &args, std::vector &rval) { Streaming *streaming = osn::IStreaming::Manager::GetInstance().find(args[0].value_union.ui64); diff --git a/obs-studio-server/source/osn-streaming.hpp b/obs-studio-server/source/osn-streaming.hpp index eb9ed1369..25cbacce1 100644 --- a/obs-studio-server/source/osn-streaming.hpp +++ b/obs-studio-server/source/osn-streaming.hpp @@ -50,6 +50,9 @@ class Streaming : public Output { lastBytesSent = 0; lastBytesSentTime = 0; simple = true; + testMode = false; + originalServiceSettings = nullptr; + originalEncoderBitrate = 0; } virtual ~Streaming(); @@ -70,6 +73,12 @@ class Streaming : public Output { uint64_t lastBytesSent; uint64_t lastBytesSentTime; bool simple; + bool testMode; + obs_data_t *originalServiceSettings; + // Bitrate the user had on videoEncoder before testBandwidth() bumped it for + // the ceiling-search measurement. Restored in CleanTestMode(). 0 = nothing + // to restore. + int originalEncoderBitrate; bool isTwitchVODSupported(); bool ApplyOutputSettings(obs_output_t *output, std::string &errorMessage); @@ -79,6 +88,17 @@ class Streaming : public Output { void setDelayLegacySettings(); void setReconnectLegacySettings(); void setNetworkLegacySettings(); + std::string testQuery(); + + // Autoconfig hooks. start() and checkOutput() are subclass-specific (different + // pipelines for simple/advanced); testBandwidth() and CleanTestMode() are shared. + virtual void start() {} + virtual void checkOutput() {} + // testBitrate: bitrate to override videoEncoder with for the duration of the + // measurement. The user's original bitrate is restored by CleanTestMode(). + // Pass 0 to leave the encoder untouched. + void testBandwidth(bool &gotError, int testBitrate = 0); + void CleanTestMode(); }; class IStreaming { diff --git a/obs-studio-server/source/osn-video.cpp b/obs-studio-server/source/osn-video.cpp index 946a50e99..15e2adc97 100644 --- a/obs-studio-server/source/osn-video.cpp +++ b/obs-studio-server/source/osn-video.cpp @@ -342,7 +342,8 @@ void osn::Video::SetVideoContext(void *data, const int64_t id, const std::vector int ret = OBS_VIDEO_FAIL; try { // Cannot disrupt video ptr inside obs while outputs are connecting - OBS_service::stopConnectingOutputs(); + // TODO APIv2 have to deprecate OBS_service and replace this call with APIv2 equivalent + //OBS_service::stopConnectingOutputs(); ret = obs_set_video_info(canvas, &video); } catch (const char *error) { blog(LOG_ERROR, "Failed to set video context %s", error); @@ -401,12 +402,11 @@ void osn::Video::RemoveVideoContext(void *data, const int64_t id, const std::vec int ret = OBS_VIDEO_FAIL; try { - // Cannot disrupt video ptr inside obs while outputs are connecting - OBS_service::stopConnectingOutputs(); + // TODO APIv2 have to deprecate OBS_service and replace this call with APIv2 equivalent + //OBS_service::stopConnectingOutputs(); ret = obs_remove_video_info(canvas); - } catch (const char *error) { blog(LOG_ERROR, "Error occurred while removing video %s", error); } diff --git a/obs-studio-server/source/util-crashmanager.cpp b/obs-studio-server/source/util-crashmanager.cpp index 04f7be3ca..d91e9f857 100644 --- a/obs-studio-server/source/util-crashmanager.cpp +++ b/obs-studio-server/source/util-crashmanager.cpp @@ -60,6 +60,7 @@ #endif #include "nodeobs_api.h" +#include "nodeobs_service.h" #include "osn-error.hpp" #include "shared.hpp" diff --git a/package.json b/package.json index 45956d251..d383e25f7 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@types/chai-subset": "^1.3.5", "@types/mocha": "^10.0.0", "@types/node": "^18.0.0", + "@types/node-media-server": "^2", "archiver": "^7.0.0", "chai": "^4.2.0", "chai-subset": "^1.6.0", @@ -44,6 +45,7 @@ "mocha": "^11.0.0", "mocha-junit-reporter": "^1.22.0", "node-addon-api": "^7.1.1", + "node-media-server": "2.7.2", "ts-node": "^7.0.1", "typedoc": "^0.28.0", "typedoc-plugin-markdown": "^4.0.0", diff --git a/tests/osn-tests/src/test_nodeobs_autoconfig.ts b/tests/osn-tests/src/test_nodeobs_autoconfig.ts index bbcf52c99..a1965a4de 100644 --- a/tests/osn-tests/src/test_nodeobs_autoconfig.ts +++ b/tests/osn-tests/src/test_nodeobs_autoconfig.ts @@ -6,9 +6,31 @@ import { ETestErrorMsg, GetErrorMessage } from '../util/error_messages'; import { OBSHandler, IConfigProgress } from '../util/obs_handler'; import { deleteConfigFiles } from '../util/general'; +// ============================================================================= +// DEPRECATED — replaced by tests/osn-tests/src/test_osn_autoconfig_v2.ts +// ============================================================================= +// This file exercises the legacy autoconfig contract: +// - obs.startAutoconfig() with no target ids +// - server-side persistence via basic config file (config_set_int / config_set_string) +// - assertions via obs.getSetting('Output', 'VBitrate') etc. +// +// Both halves of that contract are gone after the autoconfig-v2 port: +// 1. obs.startAutoconfig() is now zero-arg — the server auto-discovers all +// registered streaming targets via the IStreaming manager. The bandwidth +// test emits 'no_streaming_target_provided' if zero targets are registered. +// 2. SaveStreamSettings / SaveSettings no longer write to basic.ini — Phase 2 +// replaced them with applyResults() which mutates live osn objects via +// obs_service_update / obs_encoder_update / obs_set_video_info. The +// obs.getSetting(...) assertions below would never see the autoconfig output +// again because that path simply doesn't exist. +// +// The suite is skipped wholesale rather than deleted so the historical contract +// stays grep-able. Once the legacy nodeobs_autoconfig.cpp ifdef block and dead +// code are removed in Phase 5 cleanup, drop this file too. +// ============================================================================= const testName = 'nodeobs_autoconfig'; -describe(testName, function() { +describe.skip(testName + ' (DEPRECATED — see test_osn_autoconfig_v2.ts)', function() { this.timeout(30000) let obs: OBSHandler; let hasTestFailed: boolean = false; @@ -57,7 +79,7 @@ describe(testName, function() { let progressInfo: IConfigProgress; let settingValue: any; - obs.startAutoconfig(); + obs.startAutoconfig([]); osn.NodeObs.StartBandwidthTest(); diff --git a/tests/osn-tests/src/test_osn_autoconfig_v2.ts b/tests/osn-tests/src/test_osn_autoconfig_v2.ts new file mode 100644 index 000000000..d3aea55cd --- /dev/null +++ b/tests/osn-tests/src/test_osn_autoconfig_v2.ts @@ -0,0 +1,433 @@ +import 'mocha'; +import { expect } from 'chai'; +import * as osn from '../osn'; +import { logInfo, logEmptyLine } from '../util/logger'; +import { OBSHandler, IConfigProgress } from '../util/obs_handler'; +import { deleteConfigFiles, sleep } from '../util/general'; +import { startMockRtmp } from '../util/mock_rtmp'; +import { randomUUID } from 'crypto'; + +const testName = 'osn-autoconfig'; + +const MOCK_RTMP_PORT = 11935; +const MOCK_RTMP_PORT2 = 11936; + +describe(testName, function() { + this.timeout(120000); // bandwidth tests + apply phase + + let obs: OBSHandler; + let hasTestFailed: boolean = false; + let videoContext: osn.IVideo = null; + + let sceneName: string; + let sourceName: string; + + before(async function() { + logInfo(testName, 'Starting ' + testName + ' tests'); + deleteConfigFiles(); + obs = new OBSHandler(testName); + obs.connectOutputSignals(); + + videoContext = osn.VideoFactory.create(); + videoContext.video = { + fpsNum: 30, + fpsDen: 1, + baseWidth: 1280, + baseHeight: 720, + outputWidth: 1280, + outputHeight: 720, + outputFormat: osn.EVideoFormat.NV12, + colorspace: osn.EColorSpace.CS709, + range: osn.ERangeType.Full, + scaleType: osn.EScaleType.Lanczos, + fpsType: osn.EFPSType.Fractional, + }; + }); + + after(async function() { + if (videoContext) videoContext.destroy(); + + obs.shutdown(); + + if (hasTestFailed === true) { + logInfo(testName, 'One or more test cases failed. Uploading cache'); + await obs.uploadTestCache(); + } + + obs = null; + deleteConfigFiles(); + logInfo(testName, 'Finished ' + testName + ' tests'); + logEmptyLine(); + }); + + beforeEach(function() { + // Each case gets its own scene/source pair so failed cleanup in one case + // can't poison the next. + sceneName = 'scene_' + randomUUID(); + sourceName = 'color_source_' + randomUUID(); + const scene = osn.SceneFactory.create(sceneName); + const source = osn.InputFactory.create('color_source', sourceName); + scene.add(source); + osn.Global.setOutputSource(0, scene); + }); + + afterEach(function() { + const scene = osn.SceneFactory.fromName(sceneName); + if (scene) scene.release(); + if (this.currentTest.state === 'failed') hasTestFailed = true; + }); + + // Build a SimpleStreaming target wired to the mock RTMP server. Returned objects + // must be cleaned up by the caller via cleanupStreamingTarget(). + function buildStreamingTarget(label: string, server: string) { + const videoEncoder = osn.VideoEncoderFactory.create('obs_x264', `enc-${label}`); + videoEncoder.update({ bitrate: 2500, rate_control: 'CBR', preset: 'veryfast', keyint_sec: 2 }); + const audioEncoder = osn.AudioEncoderFactory.create('ffmpeg_aac', `aenc-${label}`); + const service = osn.ServiceFactory.create('rtmp_common', `svc-${label}`); + service.update({ service: 'Custom', server, key: `key-${label}` }); + + const stream = osn.SimpleStreamingFactory.create(); + stream.videoEncoder = videoEncoder; + stream.audioEncoder = audioEncoder; + stream.service = service; + stream.video = videoContext; + stream.delay = osn.DelayFactory.create(); + stream.reconnect = osn.ReconnectFactory.create(); + stream.network = osn.NetworkFactory.create(); + stream.enforceServiceBitrate = false; + stream.signalHandler = (signal) => obs.signals.push(signal); + return { stream, service, videoEncoder, audioEncoder }; + } + + function cleanupStreamingTarget(t: ReturnType) { + osn.SimpleStreamingFactory.destroy(t.stream); + osn.ServiceFactory.destroy(t.service); + t.videoEncoder.release(); + t.audioEncoder.release(); + } + + // Drains autoconfig events until the predicate returns true, an error event + // arrives, or the deadline elapses. Returns the full list of events seen. + async function drainUntil(stop: (ev: IConfigProgress) => boolean): Promise { + const seen: IConfigProgress[] = []; + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + const ev = await obs.getNextProgressInfo('autoconfig'); + seen.push(ev); + if (ev.event === 'error' || stop(ev)) return seen; + } + throw new Error('autoconfig drain timeout'); + } + const stageDone = (description: string) => + (ev: IConfigProgress) => ev.event === 'stopping_step' && ev.description === description; + const isDone = (ev: IConfigProgress) => ev.event === 'done'; + + // Pretty-print every resource_usage event collected during a phase. p50 is the + // typical value, p95 is the sustained ceiling (single-sample spikes from + // unrelated OS noise are dropped). Mirrors the JSON shape built by + // resourceWindowToJson() in nodeobs_autoconfig.cpp. + function logResourceEvents(events: IConfigProgress[]) { + const fmt = (n: number, d = 1) => (typeof n === 'number' ? n.toFixed(d) : 'n/a'); + for (const ev of events.filter(e => e.event === 'resource_usage')) { + try { + const p = JSON.parse(ev.payload || '{}'); + const cpu = `cpu p50/p95=${fmt(p.cpuPct?.p50)}/${fmt(p.cpuPct?.p95)}%`; + const ram = `ram p50/p95=${fmt(p.procRamMB?.p50, 0)}/${fmt(p.procRamMB?.p95, 0)}MB`; + const gpu = p.gpu?.available + ? `vram p50/p95=${p.gpu.vramUsedMB.p50}/${p.gpu.vramUsedMB.p95}MB (budget ${p.gpu.vramBudgetMB}MB)` + : 'gpu=n/a'; + logInfo(testName, `[resource ${p.phase}] samples=${p.sampleCount} dur=${p.durationMs}ms ${cpu} ${ram} ${gpu}`); + } catch (e) { + logInfo(testName, `resource_usage parse error: ${(e as Error).message} payload=${ev.payload}`); + } + } + } + + // Pull GetAutoConfigSummary().resourceUsage and log a one-line digest per window. + // Useful to confirm the summary IPC matches what came over the event stream. + function logResourceSummary() { + try { + const raw = osn.NodeObs.GetAutoConfigSummary() as string; + if (!raw) return; + const parsed = JSON.parse(raw); + const windows = parsed.resourceUsage || []; + logInfo(testName, `summary.resourceUsage: ${windows.length} window(s)`); + for (const w of windows) { + logInfo(testName, ` ${w.phase}: samples=${w.sampleCount} dur=${w.durationMs}ms cpuP95=${w.cpuPct?.p95?.toFixed?.(1)}% ramP95=${w.procRamMB?.p95?.toFixed?.(0)}MB`); + } + } catch (e) { + logInfo(testName, `summary parse error: ${(e as Error).message}`); + } + } + + it('Bandwidth test contacts the mock RTMP server', async function() { + if (obs.isDarwin()) this.skip(); + + const mockRtmp = await startMockRtmp(MOCK_RTMP_PORT); + logInfo(testName, `Mock RTMP listening on 127.0.0.1:${MOCK_RTMP_PORT}`); + + const t = buildStreamingTarget('bw', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); + try { + obs.startAutoconfig([t.stream]); + osn.NodeObs.StartBandwidthTest(); + + const events = await drainUntil(stageDone('bandwidth_test')); + logInfo(testName, `bandwidth events: ${JSON.stringify(events.filter(e => e.event !== 'resource_usage'))} mock conns=${mockRtmp.getConnections()} bytes=${mockRtmp.getBytes()}`); + logResourceEvents(events); + logResourceSummary(); + + expect(mockRtmp.getConnections()).to.be.greaterThan(0, + 'Mock RTMP saw no connection — autoconfig did not dial the configured server'); + + const errorEvent = events.find((e) => e.event === 'error'); + expect(errorEvent).to.equal(undefined, + `Bandwidth test failed with: ${errorEvent?.description}`); + } finally { + cleanupStreamingTarget(t); + await mockRtmp.close(); + } + }); + + it('Apply phase lands defaults on live osn objects', async function() { + // SetDefaultSettings populates runContext with hardcoded, known values + // (idealResolutionCX=1280, CY=720, FPSNum=30, idealBitrate=4500). SaveSettings + // then runs applyResults() which discovers all registered streaming targets + // and pushes those values into them. After 'done', the live objects' Get* + // methods should report the applied values. + + // Use a fresh video context here. The shared `videoContext` from before() + // gets touched by other tests' streaming pipelines and ends up with libobs + // applying its own canonicalisation to fps_num — easier to start clean. + const localVideo = osn.VideoFactory.create(); + localVideo.video = { + fpsNum: 60, fpsDen: 1, + baseWidth: 1920, baseHeight: 1080, + outputWidth: 1920, outputHeight: 1080, + outputFormat: osn.EVideoFormat.NV12, + colorspace: osn.EColorSpace.CS709, + range: osn.ERangeType.Full, + scaleType: osn.EScaleType.Lanczos, + fpsType: osn.EFPSType.Fractional, + }; + + const t = buildStreamingTarget('apply', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); + // Re-point this stream's video at the local context. + t.stream.video = localVideo; + try { + const before = localVideo.video; + logInfo(testName, `pre-apply video: ${JSON.stringify(before)}`); + + obs.startAutoconfig([t.stream]); + + osn.NodeObs.StartSetDefaultSettings(); + await drainUntil(stageDone('setting_default_settings')); + + osn.NodeObs.StartSaveSettings(); + const events = await drainUntil(isDone); + logInfo(testName, `apply events: ${JSON.stringify(events)}`); + const terminal = events[events.length - 1]; + expect(terminal.event).to.equal('done', `Expected terminal 'done', got '${terminal.event}/${terminal.description}'`); + + // SetDefaultSettings sets idealBitrate=4500 — applyResults forwards that + // to the videoEncoder via obs_encoder_update, capped by EstimateUpperBitrate + // for the chosen resolution. For 1280x720@30 the cap is ~3000 kbps, so the + // applied value will be in (initial=2500, idealBitrate=4500]. + const appliedBitrate = t.videoEncoder.settings['bitrate'] as number; + expect(appliedBitrate).to.be.greaterThan(2500, `bitrate did not change from initial 2500: got ${appliedBitrate}`); + expect(appliedBitrate).to.be.lessThanOrEqual(4500, `bitrate exceeded idealBitrate 4500: got ${appliedBitrate}`); + + // SetDefaultSettings sets idealResolution 1280x720 — applyResults forwards + // via obs_set_video_info on the videoId canvas. + // + // Note on FPS: libobs's obs_set_video_info only changes output_width / + // output_height at runtime. fps_num is locked once the video pipeline is + // alive and only updates on a destroy+recreate of the video context. We + // therefore assert width/height landed but not fpsNum — the frontend has + // to drop and recreate the canvas to take a new framerate. + const v = localVideo.video; + logInfo(testName, `post-apply video: ${JSON.stringify(v)}`); + expect(v.outputWidth).to.equal(1280, `expected outputWidth 1280, got ${v.outputWidth}`); + expect(v.outputHeight).to.equal(720, `expected outputHeight 720, got ${v.outputHeight}`); + } finally { + cleanupStreamingTarget(t); + localVideo.destroy(); + } + }); + + it('Autoconfig with no streaming target reports an error event', async function() { + // Empty target list — server should reject with no_streaming_targets_provided + // during bandwidth test. + obs.startAutoconfig([]); + osn.NodeObs.StartBandwidthTest(); + + const events = await drainUntil(stageDone('bandwidth_test')); + // Need at least one error event; description identifies the missing target. + const errorEvent = events.find((e) => e.event === 'error'); + expect(errorEvent).to.not.equal(undefined, 'Expected an error event'); + expect(errorEvent.description).to.equal('no_streaming_targets_provided'); + }); + + it('TerminateAutoConfig mid-bandwidth-test leaves live values untouched', async function() { + if (obs.isDarwin()) this.skip(); + + const mockRtmp = await startMockRtmp(MOCK_RTMP_PORT); + const t = buildStreamingTarget('cancel', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); + const beforeBitrate = t.videoEncoder.settings['bitrate'] as number; + const beforeServer = t.service.settings['server'] as string; + + try { + obs.startAutoconfig([t.stream]); + osn.NodeObs.StartBandwidthTest(); + + await sleep(500); + osn.NodeObs.TerminateAutoConfig(); + + // TerminateAutoConfig sets the cancel flag and kills the client-side + // polling worker. The bandwidth thread may still be winding down + // asynchronously — give it a moment, then verify values are untouched. + // We intentionally do NOT drainUntil() here because the worker that + // delivers events has already been stopped. + await sleep(1000); + + expect(t.videoEncoder.settings['bitrate']).to.equal(beforeBitrate, 'Bitrate must not change on cancel'); + expect(t.service.settings['server']).to.equal(beforeServer, 'Server URL must not change on cancel'); + } finally { + cleanupStreamingTarget(t); + await mockRtmp.close(); + } + }); + + // ---- Encoder-phase tests (resource sampling exercise) ---- + + it('Stream encoder test surfaces resource_usage', async function() { + if (obs.isDarwin()) this.skip(); + + const t = buildStreamingTarget('senc', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); + try { + obs.startAutoconfig([t.stream]); + + osn.NodeObs.StartStreamEncoderTest(); + const events = await drainUntil(stageDone('runContext.streamingEncoder_test')); + logInfo(testName, `stream-encoder events: ${JSON.stringify(events.filter(e => e.event !== 'resource_usage'))}`); + logResourceEvents(events); + logResourceSummary(); + + const errorEvent = events.find((e) => e.event === 'error'); + expect(errorEvent).to.equal(undefined, + `Stream encoder test failed with: ${errorEvent?.description}`); + + const resEvents = events.filter(e => e.event === 'resource_usage'); + expect(resEvents.length).to.be.greaterThan(0, 'expected at least one resource_usage event'); + for (const r of resEvents) { + const p = JSON.parse(r.payload || '{}'); + expect(p.sampleCount).to.be.greaterThan(0, `resource window for ${p.phase} had no samples`); + } + } finally { + cleanupStreamingTarget(t); + } + }); + + it('Recording encoder test surfaces resource_usage', async function() { + if (obs.isDarwin()) this.skip(); + + const t = buildStreamingTarget('renc', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); + try { + obs.startAutoconfig([t.stream]); + + osn.NodeObs.StartRecordingEncoderTest(); + const events = await drainUntil(stageDone('runContext.recordingEncoder_test')); + logInfo(testName, `recording-encoder events: ${JSON.stringify(events.filter(e => e.event !== 'resource_usage'))}`); + logResourceEvents(events); + logResourceSummary(); + + const errorEvent = events.find((e) => e.event === 'error'); + expect(errorEvent).to.equal(undefined, + `Recording encoder test failed with: ${errorEvent?.description}`); + + const resEvents = events.filter(e => e.event === 'resource_usage'); + expect(resEvents.length).to.be.greaterThan(0, 'expected at least one resource_usage event'); + } finally { + cleanupStreamingTarget(t); + } + }); + + // ---- Dual-target (Dual Output) tests ---- + + it('Dual-target bandwidth test contacts both mock RTMP servers', async function() { + if (obs.isDarwin()) this.skip(); + + const mockRtmp1 = await startMockRtmp(MOCK_RTMP_PORT); + const mockRtmp2 = await startMockRtmp(MOCK_RTMP_PORT2); + logInfo(testName, `Mock RTMP listening on ports ${MOCK_RTMP_PORT} and ${MOCK_RTMP_PORT2}`); + + const t1 = buildStreamingTarget('dual-bw1', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); + const t2 = buildStreamingTarget('dual-bw2', `rtmp://127.0.0.1:${MOCK_RTMP_PORT2}/live`); + try { + obs.startAutoconfig([t1.stream, t2.stream]); + osn.NodeObs.StartBandwidthTest(); + + const events = await drainUntil(stageDone('bandwidth_test')); + logInfo(testName, `dual-bw events: ${JSON.stringify(events.filter(e => e.event !== 'resource_usage'))} mock1 conns=${mockRtmp1.getConnections()} mock2 conns=${mockRtmp2.getConnections()}`); + logResourceEvents(events); + logResourceSummary(); + + expect(mockRtmp1.getConnections()).to.be.greaterThan(0, + 'Mock RTMP #1 saw no connection — primary target was not tested'); + expect(mockRtmp2.getConnections()).to.be.greaterThan(0, + 'Mock RTMP #2 saw no connection — secondary target was not tested'); + } finally { + cleanupStreamingTarget(t1); + cleanupStreamingTarget(t2); + await mockRtmp1.close(); + await mockRtmp2.close(); + } + }); + + it('Apply phase with dual targets applies per-target values', async function() { + const localVideo = osn.VideoFactory.create(); + localVideo.video = { + fpsNum: 60, fpsDen: 1, + baseWidth: 1920, baseHeight: 1080, + outputWidth: 1920, outputHeight: 1080, + outputFormat: osn.EVideoFormat.NV12, + colorspace: osn.EColorSpace.CS709, + range: osn.ERangeType.Full, + scaleType: osn.EScaleType.Lanczos, + fpsType: osn.EFPSType.Fractional, + }; + + const t1 = buildStreamingTarget('dual-apply1', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); + const t2 = buildStreamingTarget('dual-apply2', `rtmp://127.0.0.1:${MOCK_RTMP_PORT2}/live`); + t1.stream.video = localVideo; + t2.stream.video = localVideo; + + try { + obs.startAutoconfig([t1.stream, t2.stream]); + + osn.NodeObs.StartSetDefaultSettings(); + await drainUntil(stageDone('setting_default_settings')); + + osn.NodeObs.StartSaveSettings(); + const events = await drainUntil(isDone); + logInfo(testName, `dual-apply events: ${JSON.stringify(events)}`); + + const terminal = events[events.length - 1]; + expect(terminal.event).to.equal('done', `Expected terminal 'done', got '${terminal.event}/${terminal.description}'`); + + // Both targets should have received a bitrate update from applyResults. + const br1 = t1.videoEncoder.settings['bitrate'] as number; + const br2 = t2.videoEncoder.settings['bitrate'] as number; + expect(br1).to.be.greaterThan(2500, `target1 bitrate not updated: got ${br1}`); + expect(br2).to.be.greaterThan(2500, `target2 bitrate not updated: got ${br2}`); + + // Video canvas should have been updated too. + const v = localVideo.video; + expect(v.outputWidth).to.equal(1280, `expected outputWidth 1280, got ${v.outputWidth}`); + expect(v.outputHeight).to.equal(720, `expected outputHeight 720, got ${v.outputHeight}`); + } finally { + cleanupStreamingTarget(t1); + cleanupStreamingTarget(t2); + localVideo.destroy(); + } + }); +}); diff --git a/tests/osn-tests/util/mock_rtmp.ts b/tests/osn-tests/util/mock_rtmp.ts new file mode 100644 index 000000000..6ef561d49 --- /dev/null +++ b/tests/osn-tests/util/mock_rtmp.ts @@ -0,0 +1,91 @@ +// Mock RTMP server for autoconfig bandwidth tests, backed by node-media-server. +// +// node-media-server handles the full RTMP protocol (handshake, AMF +// connect/createStream/publish) so libOBS transitions into "publishing" state +// and produces real obs_output_get_total_bytes() values. +// +// Usage: +// const mock = await startMockRtmp(11935); +// // ... run autoconfig pointed at rtmp://127.0.0.1:11935/live ... +// expect(mock.getConnections()).to.be.greaterThan(0); +// await mock.close(); + +import * as net from 'net'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const NodeMediaServer = require('node-media-server'); + +export interface IMockRtmp { + port: number; + getBytes: () => number; + getConnections: () => number; + close: () => Promise; +} + +function waitForPort(port: number, timeoutMs: number = 10000): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + function tryConnect() { + if (Date.now() - start > timeoutMs) { + reject(new Error(`Timed out waiting for port ${port} to open`)); + return; + } + const sock = new net.Socket(); + sock.once('connect', () => { + sock.destroy(); + resolve(); + }); + sock.once('error', () => { + sock.destroy(); + setTimeout(tryConnect, 50); + }); + sock.connect(port, '127.0.0.1'); + } + tryConnect(); + }); +} + +export async function startMockRtmp(port: number): Promise { + let connections = 0; + let totalBytes = 0; + + const nms = new NodeMediaServer({ + logType: 0, + rtmp: { + port, + chunk_size: 60000, + gop_cache: false, + ping: 0, + ping_timeout: 60, + }, + }); + + nms.on('postPublish', () => { + connections++; + }); + + nms.on('postConnect', (_id: string, args: any) => { + const session = nms.getSession(_id); + if (session && session.socket) { + session.socket.on('data', (chunk: Buffer) => { + totalBytes += chunk.length; + }); + } + }); + + nms.run(); + + // Wait until the RTMP port is actually accepting connections. + await waitForPort(port); + + return { + port, + getBytes: () => totalBytes, + getConnections: () => connections, + close: () => + new Promise((resolve) => { + nms.stop(); + resolve(); + }), + }; +} diff --git a/tests/osn-tests/util/obs_handler.ts b/tests/osn-tests/util/obs_handler.ts index 078a5e373..7b86067d9 100644 --- a/tests/osn-tests/util/obs_handler.ts +++ b/tests/osn-tests/util/obs_handler.ts @@ -39,6 +39,7 @@ export interface IConfigProgress { description: string; percentage?: number; continent?: string; + payload?: string; } export interface IVec2 { @@ -66,7 +67,17 @@ export type TOBSHotkey = { HotkeyId: number; }; -export type TConfigEvent = 'starting_step' | 'progress' | 'stopping_step' | 'error' | 'done'; +export type TConfigEvent = + | 'starting_step' + | 'progress' + | 'stopping_step' + | 'error' + | 'done' + | 'bandwidth_result' + | 'selection_decision' + | 'video_decision' + | 'encoder_detection' + | 'resource_usage'; // OBSHandler class export class OBSHandler { @@ -480,15 +491,18 @@ export class OBSHandler { throw new Error(timeoutMessage); } - startAutoconfig() { - osn.NodeObs.InitializeAutoConfig((progressInfo: IConfigProgress) => { - if (progressInfo.event === 'stopping_step' || progressInfo.event === 'done' || progressInfo.event === 'error') { + startAutoconfig(streamings: osn.IStreaming[]) { + // Drop any progress events left over from a prior run so the next drain + // sees only this run's events. + this.progress = new WaitQueue(); + + osn.NodeObs.InitializeAutoConfig(streamings, (progressInfo: IConfigProgress) => { + if (progressInfo.event === 'stopping_step' || progressInfo.event === 'done' + || progressInfo.event === 'error' || (progressInfo.event as string) === 'applied' + || progressInfo.event === 'resource_usage') { this.progress.push(progressInfo); } - }, - { - service_name: 'Twitch', - }); + }); } getNextProgressInfo(autoconfigStep: string): Promise { diff --git a/yarn.lock b/yarn.lock index 04a3c1403..caec67d7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -88,490 +88,493 @@ __metadata: linkType: hard "@aws-sdk/client-s3@npm:^3.0.0": - version: 3.1009.0 - resolution: "@aws-sdk/client-s3@npm:3.1009.0" + version: 3.1038.0 + resolution: "@aws-sdk/client-s3@npm:3.1038.0" dependencies: "@aws-crypto/sha1-browser": "npm:5.2.0" "@aws-crypto/sha256-browser": "npm:5.2.0" "@aws-crypto/sha256-js": "npm:5.2.0" - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/credential-provider-node": "npm:^3.972.21" - "@aws-sdk/middleware-bucket-endpoint": "npm:^3.972.8" - "@aws-sdk/middleware-expect-continue": "npm:^3.972.8" - "@aws-sdk/middleware-flexible-checksums": "npm:^3.973.6" - "@aws-sdk/middleware-host-header": "npm:^3.972.8" - "@aws-sdk/middleware-location-constraint": "npm:^3.972.8" - "@aws-sdk/middleware-logger": "npm:^3.972.8" - "@aws-sdk/middleware-recursion-detection": "npm:^3.972.8" - "@aws-sdk/middleware-sdk-s3": "npm:^3.972.20" - "@aws-sdk/middleware-ssec": "npm:^3.972.8" - "@aws-sdk/middleware-user-agent": "npm:^3.972.21" - "@aws-sdk/region-config-resolver": "npm:^3.972.8" - "@aws-sdk/signature-v4-multi-region": "npm:^3.996.8" - "@aws-sdk/types": "npm:^3.973.6" - "@aws-sdk/util-endpoints": "npm:^3.996.5" - "@aws-sdk/util-user-agent-browser": "npm:^3.972.8" - "@aws-sdk/util-user-agent-node": "npm:^3.973.7" - "@smithy/config-resolver": "npm:^4.4.11" - "@smithy/core": "npm:^3.23.11" - "@smithy/eventstream-serde-browser": "npm:^4.2.12" - "@smithy/eventstream-serde-config-resolver": "npm:^4.3.12" - "@smithy/eventstream-serde-node": "npm:^4.2.12" - "@smithy/fetch-http-handler": "npm:^5.3.15" - "@smithy/hash-blob-browser": "npm:^4.2.13" - "@smithy/hash-node": "npm:^4.2.12" - "@smithy/hash-stream-node": "npm:^4.2.12" - "@smithy/invalid-dependency": "npm:^4.2.12" - "@smithy/md5-js": "npm:^4.2.12" - "@smithy/middleware-content-length": "npm:^4.2.12" - "@smithy/middleware-endpoint": "npm:^4.4.25" - "@smithy/middleware-retry": "npm:^4.4.42" - "@smithy/middleware-serde": "npm:^4.2.14" - "@smithy/middleware-stack": "npm:^4.2.12" - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/node-http-handler": "npm:^4.4.16" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/smithy-client": "npm:^4.12.5" - "@smithy/types": "npm:^4.13.1" - "@smithy/url-parser": "npm:^4.2.12" + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/credential-provider-node": "npm:^3.972.37" + "@aws-sdk/middleware-bucket-endpoint": "npm:^3.972.10" + "@aws-sdk/middleware-expect-continue": "npm:^3.972.10" + "@aws-sdk/middleware-flexible-checksums": "npm:^3.974.14" + "@aws-sdk/middleware-host-header": "npm:^3.972.10" + "@aws-sdk/middleware-location-constraint": "npm:^3.972.10" + "@aws-sdk/middleware-logger": "npm:^3.972.10" + "@aws-sdk/middleware-recursion-detection": "npm:^3.972.11" + "@aws-sdk/middleware-sdk-s3": "npm:^3.972.35" + "@aws-sdk/middleware-ssec": "npm:^3.972.10" + "@aws-sdk/middleware-user-agent": "npm:^3.972.36" + "@aws-sdk/region-config-resolver": "npm:^3.972.13" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.23" + "@aws-sdk/types": "npm:^3.973.8" + "@aws-sdk/util-endpoints": "npm:^3.996.8" + "@aws-sdk/util-user-agent-browser": "npm:^3.972.10" + "@aws-sdk/util-user-agent-node": "npm:^3.973.22" + "@smithy/config-resolver": "npm:^4.4.17" + "@smithy/core": "npm:^3.23.17" + "@smithy/eventstream-serde-browser": "npm:^4.2.14" + "@smithy/eventstream-serde-config-resolver": "npm:^4.3.14" + "@smithy/eventstream-serde-node": "npm:^4.2.14" + "@smithy/fetch-http-handler": "npm:^5.3.17" + "@smithy/hash-blob-browser": "npm:^4.2.15" + "@smithy/hash-node": "npm:^4.2.14" + "@smithy/hash-stream-node": "npm:^4.2.14" + "@smithy/invalid-dependency": "npm:^4.2.14" + "@smithy/md5-js": "npm:^4.2.14" + "@smithy/middleware-content-length": "npm:^4.2.14" + "@smithy/middleware-endpoint": "npm:^4.4.32" + "@smithy/middleware-retry": "npm:^4.5.6" + "@smithy/middleware-serde": "npm:^4.2.20" + "@smithy/middleware-stack": "npm:^4.2.14" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/node-http-handler": "npm:^4.6.1" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" "@smithy/util-base64": "npm:^4.3.2" "@smithy/util-body-length-browser": "npm:^4.2.2" "@smithy/util-body-length-node": "npm:^4.2.3" - "@smithy/util-defaults-mode-browser": "npm:^4.3.41" - "@smithy/util-defaults-mode-node": "npm:^4.2.44" - "@smithy/util-endpoints": "npm:^3.3.3" - "@smithy/util-middleware": "npm:^4.2.12" - "@smithy/util-retry": "npm:^4.2.12" - "@smithy/util-stream": "npm:^4.5.19" + "@smithy/util-defaults-mode-browser": "npm:^4.3.49" + "@smithy/util-defaults-mode-node": "npm:^4.2.54" + "@smithy/util-endpoints": "npm:^3.4.2" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-retry": "npm:^4.3.5" + "@smithy/util-stream": "npm:^4.5.25" "@smithy/util-utf8": "npm:^4.2.2" - "@smithy/util-waiter": "npm:^4.2.13" + "@smithy/util-waiter": "npm:^4.3.0" tslib: "npm:^2.6.2" - checksum: 10c0/45bae01e0079d9680442f3868a4f8b8a2b80cf734d26c31d86ea10294a268188edc4ef6fe3568a53004bc77c723981d19e79b3ca96d3aa813643a41f5c2765a8 + checksum: 10c0/0cc2223947cc11082d6c482563397f907de00423819e7af7b951906b9a566afe46bdda7f8a2789a3bb0ab714294d27519d83efce5d26f67d0d0b382e6ded4715 languageName: node linkType: hard -"@aws-sdk/core@npm:^3.973.20": - version: 3.973.20 - resolution: "@aws-sdk/core@npm:3.973.20" +"@aws-sdk/core@npm:^3.974.6": + version: 3.974.6 + resolution: "@aws-sdk/core@npm:3.974.6" dependencies: - "@aws-sdk/types": "npm:^3.973.6" - "@aws-sdk/xml-builder": "npm:^3.972.11" - "@smithy/core": "npm:^3.23.11" - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/signature-v4": "npm:^5.3.12" - "@smithy/smithy-client": "npm:^4.12.5" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/types": "npm:^3.973.8" + "@aws-sdk/xml-builder": "npm:^3.972.20" + "@smithy/core": "npm:^3.23.17" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/signature-v4": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" "@smithy/util-base64": "npm:^4.3.2" - "@smithy/util-middleware": "npm:^4.2.12" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-retry": "npm:^4.3.5" "@smithy/util-utf8": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/ee632c2a1a6814911c6fa647c67b3f6ab09f8f6fa3bc4b4202f40e4160d953e7a697e8a1e6983f652a6bd41b159b3173e073fa5280293499027107e4334287f9 + checksum: 10c0/39f33562fefa000c48d19a03caa8791a015c5cd4fd4dbdb034d21486c9c4435aa5670c7b48e50c2a5ccc0ef1ea248567c20e158cfa643a03bf2b63bd1cda8b87 languageName: node linkType: hard -"@aws-sdk/crc64-nvme@npm:^3.972.5": - version: 3.972.5 - resolution: "@aws-sdk/crc64-nvme@npm:3.972.5" +"@aws-sdk/crc64-nvme@npm:^3.972.7": + version: 3.972.7 + resolution: "@aws-sdk/crc64-nvme@npm:3.972.7" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/2d25cc231d36a2292d83668338d778db8db7a3be3c36a097d047df0e97016293c01371401f0fde02b5d3ce52b9c4e0db19bf5746278d9ca89ed689b916a40cfc + checksum: 10c0/c6f23e4e4c06b98009264b511567bb808d4c4f53c1da9b41f5c975f5f9f5e4b11af16e7add850e7bba29731b6efb145eb4dc0538d9639d6a5daaceadb4acf35d languageName: node linkType: hard -"@aws-sdk/credential-provider-env@npm:^3.972.18": - version: 3.972.18 - resolution: "@aws-sdk/credential-provider-env@npm:3.972.18" +"@aws-sdk/credential-provider-env@npm:^3.972.32": + version: 3.972.32 + resolution: "@aws-sdk/credential-provider-env@npm:3.972.32" dependencies: - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/8c8af7e90ae727ac380c079045700c1cdf0cf2d917a8738315ae83f323dccb6c71a85ef55b5b45604cbaeb180570d158aee6104e6c0bc3f071dbd6ed4f93b05f + checksum: 10c0/e79e908cbbb378c9aab1f600edd3094f3c999c8a119792c80f219b4a90f1c800242a31196198e578aa2290bc1d285bf09c668a032e75f23c64bcd983744dd9ab languageName: node linkType: hard -"@aws-sdk/credential-provider-http@npm:^3.972.20": - version: 3.972.20 - resolution: "@aws-sdk/credential-provider-http@npm:3.972.20" +"@aws-sdk/credential-provider-http@npm:^3.972.34": + version: 3.972.34 + resolution: "@aws-sdk/credential-provider-http@npm:3.972.34" dependencies: - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/fetch-http-handler": "npm:^5.3.15" - "@smithy/node-http-handler": "npm:^4.4.16" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/smithy-client": "npm:^4.12.5" - "@smithy/types": "npm:^4.13.1" - "@smithy/util-stream": "npm:^4.5.19" + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/fetch-http-handler": "npm:^5.3.17" + "@smithy/node-http-handler": "npm:^4.6.1" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-stream": "npm:^4.5.25" tslib: "npm:^2.6.2" - checksum: 10c0/7a8475346e5904075d48764f0a653977951240aa7da6ddd5393ff4a64fad7d1338a57f297ee6a20849d8e84e90a22b7b8c23ffaf544412f83517bb36403ea4cd - languageName: node - linkType: hard - -"@aws-sdk/credential-provider-ini@npm:^3.972.20": - version: 3.972.20 - resolution: "@aws-sdk/credential-provider-ini@npm:3.972.20" - dependencies: - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/credential-provider-env": "npm:^3.972.18" - "@aws-sdk/credential-provider-http": "npm:^3.972.20" - "@aws-sdk/credential-provider-login": "npm:^3.972.20" - "@aws-sdk/credential-provider-process": "npm:^3.972.18" - "@aws-sdk/credential-provider-sso": "npm:^3.972.20" - "@aws-sdk/credential-provider-web-identity": "npm:^3.972.20" - "@aws-sdk/nested-clients": "npm:^3.996.10" - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/credential-provider-imds": "npm:^4.2.12" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/shared-ini-file-loader": "npm:^4.4.7" - "@smithy/types": "npm:^4.13.1" + checksum: 10c0/d102fb83c9acd09b1d89d8b9bdf2aba092a61109edbae7fe8ec2cdbe0aeeff1963646930f309588e1e9352ff4e081bab3f36d32f36201f14c19a0804632c9f2c + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-ini@npm:^3.972.36": + version: 3.972.36 + resolution: "@aws-sdk/credential-provider-ini@npm:3.972.36" + dependencies: + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/credential-provider-env": "npm:^3.972.32" + "@aws-sdk/credential-provider-http": "npm:^3.972.34" + "@aws-sdk/credential-provider-login": "npm:^3.972.36" + "@aws-sdk/credential-provider-process": "npm:^3.972.32" + "@aws-sdk/credential-provider-sso": "npm:^3.972.36" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.36" + "@aws-sdk/nested-clients": "npm:^3.997.4" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/credential-provider-imds": "npm:^4.2.14" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/f4ce3dd4cee3c48f68d1ab87b115b7b982b3abf47499d375d16c88ce3fa57f677967da962629023ae4687203f6ea278702b48619880f56aa425bbe7d41067065 + checksum: 10c0/e86b7d4896c461b2318f749d7440e68b02d00da7a62a4c594df1b0c78bcc30643bc85eddf43e880416e02532fa26d40411acbff1c1e1673de2e6881305f8d2ca languageName: node linkType: hard -"@aws-sdk/credential-provider-login@npm:^3.972.20": - version: 3.972.20 - resolution: "@aws-sdk/credential-provider-login@npm:3.972.20" +"@aws-sdk/credential-provider-login@npm:^3.972.36": + version: 3.972.36 + resolution: "@aws-sdk/credential-provider-login@npm:3.972.36" dependencies: - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/nested-clients": "npm:^3.996.10" - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/shared-ini-file-loader": "npm:^4.4.7" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/nested-clients": "npm:^3.997.4" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/97844214ea37854efd23a7d6a1ae7bdba2ce61533d0c367286c945fa7f3a0bbace53fc9c1f6dbf174a1cb868625c16f66906603fe2f54aad68c23e4f92c25548 + checksum: 10c0/9446602c8d8bdca2ca529b45c91ec24ae4d877b380d7ff2ea1c333b77651de8556dc0632595ab2a33868a66e84fcbbb1cd3f25b2b1985025ab39a9d4fe3ef5b0 languageName: node linkType: hard -"@aws-sdk/credential-provider-node@npm:^3.972.21": - version: 3.972.21 - resolution: "@aws-sdk/credential-provider-node@npm:3.972.21" - dependencies: - "@aws-sdk/credential-provider-env": "npm:^3.972.18" - "@aws-sdk/credential-provider-http": "npm:^3.972.20" - "@aws-sdk/credential-provider-ini": "npm:^3.972.20" - "@aws-sdk/credential-provider-process": "npm:^3.972.18" - "@aws-sdk/credential-provider-sso": "npm:^3.972.20" - "@aws-sdk/credential-provider-web-identity": "npm:^3.972.20" - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/credential-provider-imds": "npm:^4.2.12" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/shared-ini-file-loader": "npm:^4.4.7" - "@smithy/types": "npm:^4.13.1" +"@aws-sdk/credential-provider-node@npm:^3.972.37": + version: 3.972.37 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.37" + dependencies: + "@aws-sdk/credential-provider-env": "npm:^3.972.32" + "@aws-sdk/credential-provider-http": "npm:^3.972.34" + "@aws-sdk/credential-provider-ini": "npm:^3.972.36" + "@aws-sdk/credential-provider-process": "npm:^3.972.32" + "@aws-sdk/credential-provider-sso": "npm:^3.972.36" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.36" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/credential-provider-imds": "npm:^4.2.14" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/cecf760a3048e535dc79e4a24ce0ade784ba2f287d53a7fc63b541687870ded2f1938cd8c415414e794b50a1e0c4f29badcdd39fc8f57c58e8e7a83260ef60f8 + checksum: 10c0/6e87376ee86abbc9e3ff7e92d60031342e9e002d4ebb589150dec43a755f9b3012a6083f293241ff43919938fc35bdc3a21b1e20fe1ffbf2b1c0f982186e1f53 languageName: node linkType: hard -"@aws-sdk/credential-provider-process@npm:^3.972.18": - version: 3.972.18 - resolution: "@aws-sdk/credential-provider-process@npm:3.972.18" +"@aws-sdk/credential-provider-process@npm:^3.972.32": + version: 3.972.32 + resolution: "@aws-sdk/credential-provider-process@npm:3.972.32" dependencies: - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/shared-ini-file-loader": "npm:^4.4.7" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/ddbb1fd6590fd4fcdd0bf8b2a749e3bc356697765747b0aff01a47cdd6da153ffa77b95de5fc2cfbf1acdfbbc49d710572aee3087034c9977923af1ec505783c + checksum: 10c0/9f3d91414a82b88400220898dc2f49f0aaea4ba84099fbc16c618d24f561d76fed7c39fb90264737fe9a249bb3ef2a74311a217dbb28799f84830668f0818b9a languageName: node linkType: hard -"@aws-sdk/credential-provider-sso@npm:^3.972.20": - version: 3.972.20 - resolution: "@aws-sdk/credential-provider-sso@npm:3.972.20" +"@aws-sdk/credential-provider-sso@npm:^3.972.36": + version: 3.972.36 + resolution: "@aws-sdk/credential-provider-sso@npm:3.972.36" dependencies: - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/nested-clients": "npm:^3.996.10" - "@aws-sdk/token-providers": "npm:3.1009.0" - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/shared-ini-file-loader": "npm:^4.4.7" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/nested-clients": "npm:^3.997.4" + "@aws-sdk/token-providers": "npm:3.1038.0" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/641796c879a526576d5bc3f82ab405f2883409aea583dba939a642d9ac1a920f64543b5c76cf1748a580db5e26f7789a887197e385fef81f366a4338c2a11785 + checksum: 10c0/610ca7ae5e3f9fdb922a7a90c3a066655129e90de9b4887734dc2aaa4a4569c7539e927eaad8c313e338556345c2794ad42ac9ba4a63aa0796f9f2e0b06dff93 languageName: node linkType: hard -"@aws-sdk/credential-provider-web-identity@npm:^3.972.20": - version: 3.972.20 - resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.20" +"@aws-sdk/credential-provider-web-identity@npm:^3.972.36": + version: 3.972.36 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.36" dependencies: - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/nested-clients": "npm:^3.996.10" - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/shared-ini-file-loader": "npm:^4.4.7" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/nested-clients": "npm:^3.997.4" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/581139aa40b2e82f7b8a7aa2b69184aadaf6453c91f79662e6e5647f383884fe3b7cb522205d5d8fdd5985e24f0ee5c320c0d3e813654e99dd41b819a01c4a71 + checksum: 10c0/a7a51795252fc8ca4f28d866c436245b291dd411e12b8da9cebcfc839aea2a3b54ea2c7b905f5cfeabb3ad7ddd13fa8ff86e6e0d467675854dbf3dd2f7d06fc2 languageName: node linkType: hard "@aws-sdk/lib-storage@npm:^3.0.0": - version: 3.1009.0 - resolution: "@aws-sdk/lib-storage@npm:3.1009.0" + version: 3.1038.0 + resolution: "@aws-sdk/lib-storage@npm:3.1038.0" dependencies: - "@smithy/abort-controller": "npm:^4.2.12" - "@smithy/middleware-endpoint": "npm:^4.4.25" - "@smithy/smithy-client": "npm:^4.12.5" + "@smithy/middleware-endpoint": "npm:^4.4.32" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" buffer: "npm:5.6.0" events: "npm:3.3.0" stream-browserify: "npm:3.0.0" tslib: "npm:^2.6.2" peerDependencies: - "@aws-sdk/client-s3": ^3.1009.0 - checksum: 10c0/24a8a133a334ca0403a7899e5897a4ac946abee2dfc18a45e5929c9065df051a5ed74a6c97c13ce455c202b5ba41818ae56d931eda3d08f628723811e6d1deb9 + "@aws-sdk/client-s3": ^3.1038.0 + checksum: 10c0/b269f3245d9284b527c3981e6d88300b5cbdd7bbdb00e8eec5c6308dc52ffd532b35c800cdb161a24d274ed515753786be6ba9b9b72ff9af35f72fd602c04f1a languageName: node linkType: hard -"@aws-sdk/middleware-bucket-endpoint@npm:^3.972.8": - version: 3.972.8 - resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.972.8" +"@aws-sdk/middleware-bucket-endpoint@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.972.10" dependencies: - "@aws-sdk/types": "npm:^3.973.6" + "@aws-sdk/types": "npm:^3.973.8" "@aws-sdk/util-arn-parser": "npm:^3.972.3" - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" "@smithy/util-config-provider": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/03cb3ae1e28cd1f7abcd6363cbba5f550a1fe3d0daf9752ec758f8928e2dc7a1eed9e21a6c94c31760dc96ca60984910384a3c3599047f44c9148953dc0683bc + checksum: 10c0/5529288142e0ebfbd985a257dbbb0f3510981a6ef56ce449465458ca12f7bcbbb9bfba9e1788c925329443604d4a438275f30254ef1d47e7931da798fb6e6765 languageName: node linkType: hard -"@aws-sdk/middleware-expect-continue@npm:^3.972.8": - version: 3.972.8 - resolution: "@aws-sdk/middleware-expect-continue@npm:3.972.8" +"@aws-sdk/middleware-expect-continue@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/middleware-expect-continue@npm:3.972.10" dependencies: - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/dbfb4b54aea5d43fa49fae9c55c5f3cd9e274c06c9a285795a9ba8cdb8e70062a1f05fa44f4cbc03374cc198f423c5f7c97d888485eb52334658323348449c99 + checksum: 10c0/c91588169621597bed09aa53f9bf858b83a0c8b8b84d6838ed3729c8222a7b7d5819595fd2617ca8f859e8471ca7e7132bb7d1694d5ada17039dea53cc707e4b languageName: node linkType: hard -"@aws-sdk/middleware-flexible-checksums@npm:^3.973.6": - version: 3.973.6 - resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.973.6" +"@aws-sdk/middleware-flexible-checksums@npm:^3.974.14": + version: 3.974.14 + resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.974.14" dependencies: "@aws-crypto/crc32": "npm:5.2.0" "@aws-crypto/crc32c": "npm:5.2.0" "@aws-crypto/util": "npm:5.2.0" - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/crc64-nvme": "npm:^3.972.5" - "@aws-sdk/types": "npm:^3.973.6" + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/crc64-nvme": "npm:^3.972.7" + "@aws-sdk/types": "npm:^3.973.8" "@smithy/is-array-buffer": "npm:^4.2.2" - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" - "@smithy/util-middleware": "npm:^4.2.12" - "@smithy/util-stream": "npm:^4.5.19" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-stream": "npm:^4.5.25" "@smithy/util-utf8": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/8f6fc640e590a5d47abefea459249771cd0b8c823902cd620290e452a3bb0ad7314eed8b2a388db39fe31ae76d2bfeecceabcc60560e1311cd4fe5eb0ab0a6e6 + checksum: 10c0/693183ea334354dade00b0469dd85363490ce49c28cce57fdfe0845288a0503b69c89c652fa84b47ba784635c137e38777fa6833d7d752e9190395530b28a0eb languageName: node linkType: hard -"@aws-sdk/middleware-host-header@npm:^3.972.8": - version: 3.972.8 - resolution: "@aws-sdk/middleware-host-header@npm:3.972.8" +"@aws-sdk/middleware-host-header@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/middleware-host-header@npm:3.972.10" dependencies: - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/f3019810e447a53788c546b94bc40a20c543aa067abf6235643d8e24689f8d4edec211297ac464380fb58c79f99803d1a152027798a3b401eab225e679a85d07 + checksum: 10c0/e631b48f8d8fd40f8977da8d1fc012208f953000dd5645dc0a700c7283af8fafb996c9c1c50e40b8392c402ad98d11e0ddddb949896db5163a7f06e2385e619a languageName: node linkType: hard -"@aws-sdk/middleware-location-constraint@npm:^3.972.8": - version: 3.972.8 - resolution: "@aws-sdk/middleware-location-constraint@npm:3.972.8" +"@aws-sdk/middleware-location-constraint@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/middleware-location-constraint@npm:3.972.10" dependencies: - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/3819ad39a601cccb0a9743b13b37dbbdf3e2f7c3c34d15d4b09ef50f78683489502b1125f31b30ba45924db5c3fbc8b2d1be3cb31a443a53538fc1eb36615eff + checksum: 10c0/ef8ef1f3cf7d28e5b02edcc2b62cab07a380f7a02983bdfcaf24fbea35129c53ac5a1f5846ab28212b649d6c81f437e2a846f1f954fb374509ea174201bf09d4 languageName: node linkType: hard -"@aws-sdk/middleware-logger@npm:^3.972.8": - version: 3.972.8 - resolution: "@aws-sdk/middleware-logger@npm:3.972.8" +"@aws-sdk/middleware-logger@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/middleware-logger@npm:3.972.10" dependencies: - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/79240b2a34d020f90f54982a4744b0a6bc5b5a7de6442f3b6657b2f10a76d9a1d3bcc2887a1d96d0aa5da4a09b3ce2a77df7a0d4e7e2973d1797ff6d8e8800a9 + checksum: 10c0/a24e0c98b3cf6c9b7960bf8979d2ab8f839fb89294ba8943136d99dbe7370cc45b010460ed7f5bf14ab6ad8e113ccec6387cf1bda655bcbdb58722df21d9b713 languageName: node linkType: hard -"@aws-sdk/middleware-recursion-detection@npm:^3.972.8": - version: 3.972.8 - resolution: "@aws-sdk/middleware-recursion-detection@npm:3.972.8" +"@aws-sdk/middleware-recursion-detection@npm:^3.972.11": + version: 3.972.11 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.972.11" dependencies: - "@aws-sdk/types": "npm:^3.973.6" + "@aws-sdk/types": "npm:^3.973.8" "@aws/lambda-invoke-store": "npm:^0.2.2" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/8d8ef442befd65dd9175294ae292e2b421171c0c9db9389a6f504b97e055dc9c3b51a80c711792fbc31cd3b4976f1d71d30a378063416553e17a59f70e7eb6d1 + checksum: 10c0/e52501b00e79e714218897ddec9edd40ecf33fdb43c19b00195c8ed71c8295d0d148f07465465d464f103a23aa535dc1daf60bbb5b95303e330767a5eba78f54 languageName: node linkType: hard -"@aws-sdk/middleware-sdk-s3@npm:^3.972.20": - version: 3.972.20 - resolution: "@aws-sdk/middleware-sdk-s3@npm:3.972.20" +"@aws-sdk/middleware-sdk-s3@npm:^3.972.35": + version: 3.972.35 + resolution: "@aws-sdk/middleware-sdk-s3@npm:3.972.35" dependencies: - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/types": "npm:^3.973.6" + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/types": "npm:^3.973.8" "@aws-sdk/util-arn-parser": "npm:^3.972.3" - "@smithy/core": "npm:^3.23.11" - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/signature-v4": "npm:^5.3.12" - "@smithy/smithy-client": "npm:^4.12.5" - "@smithy/types": "npm:^4.13.1" + "@smithy/core": "npm:^3.23.17" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/signature-v4": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" "@smithy/util-config-provider": "npm:^4.2.2" - "@smithy/util-middleware": "npm:^4.2.12" - "@smithy/util-stream": "npm:^4.5.19" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-stream": "npm:^4.5.25" "@smithy/util-utf8": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/b504c38ea983c86a409359d03a60ab00043187c37d13ad6c7221c1fe790d777216fa994d0cab9aacea38b5664adf071235299bb9aeed75d134cd2bdd80834f0a + checksum: 10c0/8e62f0ecea9f7cfad4ef8d737965960f125c60c6d510c0ea1478f1d906812a8fad46832c305da89c4396d4ab4260b94ab42941c3fb0c17c583be1e9db47c9ad7 languageName: node linkType: hard -"@aws-sdk/middleware-ssec@npm:^3.972.8": - version: 3.972.8 - resolution: "@aws-sdk/middleware-ssec@npm:3.972.8" +"@aws-sdk/middleware-ssec@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/middleware-ssec@npm:3.972.10" dependencies: - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/0d90f48273bd668d9aafe233bd4cc7e16dcda52761202ab4af377e94a112bbd4b5f0939e8dee0f85f8d17c36f1b9e565889bd3d20545145787850479bcf82651 + checksum: 10c0/13e11c485e63d6b3d8a5f14888c6b8aea1c0a96a99826e840840b6974b9605b5f528f8869b021036e8615995d9e5ecd33d6e67af1561ed673d37292d356ca441 languageName: node linkType: hard -"@aws-sdk/middleware-user-agent@npm:^3.972.21": - version: 3.972.21 - resolution: "@aws-sdk/middleware-user-agent@npm:3.972.21" - dependencies: - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/types": "npm:^3.973.6" - "@aws-sdk/util-endpoints": "npm:^3.996.5" - "@smithy/core": "npm:^3.23.11" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" - "@smithy/util-retry": "npm:^4.2.12" +"@aws-sdk/middleware-user-agent@npm:^3.972.36": + version: 3.972.36 + resolution: "@aws-sdk/middleware-user-agent@npm:3.972.36" + dependencies: + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/types": "npm:^3.973.8" + "@aws-sdk/util-endpoints": "npm:^3.996.8" + "@smithy/core": "npm:^3.23.17" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-retry": "npm:^4.3.5" tslib: "npm:^2.6.2" - checksum: 10c0/ba94964dfcf123c03cc26beeb741f02e0f9274f4c2b15ad93d1d422af03ee534964dee6f3b6439cebaba531243777a738cc34051ec97f2b9c6fc9cfe96f2c6f4 + checksum: 10c0/abba01666b59387a3db94cbee5a289ddac90a5ab03dd9b58f8617c04110e5da7969e529008b966de6981e0ef4d6cdefda1174ff806cde0a14cae38b18d215459 languageName: node linkType: hard -"@aws-sdk/nested-clients@npm:^3.996.10": - version: 3.996.10 - resolution: "@aws-sdk/nested-clients@npm:3.996.10" +"@aws-sdk/nested-clients@npm:^3.997.4": + version: 3.997.4 + resolution: "@aws-sdk/nested-clients@npm:3.997.4" dependencies: "@aws-crypto/sha256-browser": "npm:5.2.0" "@aws-crypto/sha256-js": "npm:5.2.0" - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/middleware-host-header": "npm:^3.972.8" - "@aws-sdk/middleware-logger": "npm:^3.972.8" - "@aws-sdk/middleware-recursion-detection": "npm:^3.972.8" - "@aws-sdk/middleware-user-agent": "npm:^3.972.21" - "@aws-sdk/region-config-resolver": "npm:^3.972.8" - "@aws-sdk/types": "npm:^3.973.6" - "@aws-sdk/util-endpoints": "npm:^3.996.5" - "@aws-sdk/util-user-agent-browser": "npm:^3.972.8" - "@aws-sdk/util-user-agent-node": "npm:^3.973.7" - "@smithy/config-resolver": "npm:^4.4.11" - "@smithy/core": "npm:^3.23.11" - "@smithy/fetch-http-handler": "npm:^5.3.15" - "@smithy/hash-node": "npm:^4.2.12" - "@smithy/invalid-dependency": "npm:^4.2.12" - "@smithy/middleware-content-length": "npm:^4.2.12" - "@smithy/middleware-endpoint": "npm:^4.4.25" - "@smithy/middleware-retry": "npm:^4.4.42" - "@smithy/middleware-serde": "npm:^4.2.14" - "@smithy/middleware-stack": "npm:^4.2.12" - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/node-http-handler": "npm:^4.4.16" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/smithy-client": "npm:^4.12.5" - "@smithy/types": "npm:^4.13.1" - "@smithy/url-parser": "npm:^4.2.12" + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/middleware-host-header": "npm:^3.972.10" + "@aws-sdk/middleware-logger": "npm:^3.972.10" + "@aws-sdk/middleware-recursion-detection": "npm:^3.972.11" + "@aws-sdk/middleware-user-agent": "npm:^3.972.36" + "@aws-sdk/region-config-resolver": "npm:^3.972.13" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.23" + "@aws-sdk/types": "npm:^3.973.8" + "@aws-sdk/util-endpoints": "npm:^3.996.8" + "@aws-sdk/util-user-agent-browser": "npm:^3.972.10" + "@aws-sdk/util-user-agent-node": "npm:^3.973.22" + "@smithy/config-resolver": "npm:^4.4.17" + "@smithy/core": "npm:^3.23.17" + "@smithy/fetch-http-handler": "npm:^5.3.17" + "@smithy/hash-node": "npm:^4.2.14" + "@smithy/invalid-dependency": "npm:^4.2.14" + "@smithy/middleware-content-length": "npm:^4.2.14" + "@smithy/middleware-endpoint": "npm:^4.4.32" + "@smithy/middleware-retry": "npm:^4.5.6" + "@smithy/middleware-serde": "npm:^4.2.20" + "@smithy/middleware-stack": "npm:^4.2.14" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/node-http-handler": "npm:^4.6.1" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" "@smithy/util-base64": "npm:^4.3.2" "@smithy/util-body-length-browser": "npm:^4.2.2" "@smithy/util-body-length-node": "npm:^4.2.3" - "@smithy/util-defaults-mode-browser": "npm:^4.3.41" - "@smithy/util-defaults-mode-node": "npm:^4.2.44" - "@smithy/util-endpoints": "npm:^3.3.3" - "@smithy/util-middleware": "npm:^4.2.12" - "@smithy/util-retry": "npm:^4.2.12" + "@smithy/util-defaults-mode-browser": "npm:^4.3.49" + "@smithy/util-defaults-mode-node": "npm:^4.2.54" + "@smithy/util-endpoints": "npm:^3.4.2" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-retry": "npm:^4.3.5" "@smithy/util-utf8": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/fc0083d9991e2e8ab82d5be45a6e40f9f31b183b61450221bd3bcbd55bd55c6d8ac0899dd2c479e8f513f265f776385b0faa20a887d0a78e51ff5793acea3bcf + checksum: 10c0/a5fc46ba3b2fa8204519c24a0341641f368def8bda7a188fb9278dc0612659720ac1a887cdf9419d137340c1cb7e03070e13c7f336ea9473abbd79c8a0b5b46f languageName: node linkType: hard -"@aws-sdk/region-config-resolver@npm:^3.972.8": - version: 3.972.8 - resolution: "@aws-sdk/region-config-resolver@npm:3.972.8" +"@aws-sdk/region-config-resolver@npm:^3.972.13": + version: 3.972.13 + resolution: "@aws-sdk/region-config-resolver@npm:3.972.13" dependencies: - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/config-resolver": "npm:^4.4.11" - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/config-resolver": "npm:^4.4.17" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/9532a6516053a15d317c4b114492fb47c9411a4a7bded7161738c59c85df19a4b14120e376573c48b23d7a6c84c5a9e29b4f6f8e9cf0d22882e12b27001704a5 + checksum: 10c0/8cc3e5433ccf9ec4efb6d12ccd924701cfd6fb018124c9e5106486da10402ea1b83b0855353dae5e0f31ad2c7b6d962ac832350a5cdbeda59a30231525fd1118 languageName: node linkType: hard -"@aws-sdk/signature-v4-multi-region@npm:^3.996.8": - version: 3.996.8 - resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.8" +"@aws-sdk/signature-v4-multi-region@npm:^3.996.23": + version: 3.996.23 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.23" dependencies: - "@aws-sdk/middleware-sdk-s3": "npm:^3.972.20" - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/signature-v4": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/middleware-sdk-s3": "npm:^3.972.35" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/signature-v4": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/a463afb7b9b425fa513af8875866a06c4b832c914eb2a712f480f75cbfe05680633dc915c2955ce0fc29131d2aad51fe10d9222193c87b01e94f079a0f3b24d2 + checksum: 10c0/4053f41d58b36bc18ec78d64c6cbca1ebf714050d31d9554ed850f2ad7a3e932a1607806cb4e9c966fb8cabf8e8297ccb7037d6222eccf23d85a3c079a2e5fea languageName: node linkType: hard -"@aws-sdk/token-providers@npm:3.1009.0": - version: 3.1009.0 - resolution: "@aws-sdk/token-providers@npm:3.1009.0" +"@aws-sdk/token-providers@npm:3.1038.0": + version: 3.1038.0 + resolution: "@aws-sdk/token-providers@npm:3.1038.0" dependencies: - "@aws-sdk/core": "npm:^3.973.20" - "@aws-sdk/nested-clients": "npm:^3.996.10" - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/shared-ini-file-loader": "npm:^4.4.7" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/core": "npm:^3.974.6" + "@aws-sdk/nested-clients": "npm:^3.997.4" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/607345ebef04a5d32d53f61d2411da42155268a736b0f12e1094ae1a462d3deba5cab62aba9510987c4ae7d60582fcd8caf44c0ab52d81677cef10a0ca01fc1b + checksum: 10c0/88bbd39f9eeddcbc337853ccbc9e6c16ee47676509c47af80b5bad55852fa209f18b26d287d5431cdaf0b4207431213408ef4e8ed0b27f4eafd4eb77be0a3910 languageName: node linkType: hard -"@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.973.6": - version: 3.973.6 - resolution: "@aws-sdk/types@npm:3.973.6" +"@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.973.8": + version: 3.973.8 + resolution: "@aws-sdk/types@npm:3.973.8" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/3a5c65313a3faadf854dd1055e5768c0477ecd10e8a597d0c0041fb69efdcefc399bf263f86fef93754d2d9a91d4f0eb78f5f1de14779657f84a24218a457fc3 + checksum: 10c0/4823579e7dd0f6dcce9e9fea9630091eb46e3596bc468614a072f682b1eab6f048854ccf5e9398459ada175c13dfc636ffa8c1d3aa655cf6f22447f9c1a02f7f languageName: node linkType: hard @@ -584,16 +587,16 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/util-endpoints@npm:^3.996.5": - version: 3.996.5 - resolution: "@aws-sdk/util-endpoints@npm:3.996.5" +"@aws-sdk/util-endpoints@npm:^3.996.8": + version: 3.996.8 + resolution: "@aws-sdk/util-endpoints@npm:3.996.8" dependencies: - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/types": "npm:^4.13.1" - "@smithy/url-parser": "npm:^4.2.12" - "@smithy/util-endpoints": "npm:^3.3.3" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" + "@smithy/util-endpoints": "npm:^3.4.2" tslib: "npm:^2.6.2" - checksum: 10c0/6356b7b040758af210f6b3d6807c11538e8a6888093ebe8a172949532a170c1f3f0bf93db86f6a75f071749219c3da2a88e63954f53031e8c3f9a092d7d97db9 + checksum: 10c0/66f17e85357ab8e265ab7d1c9a69a064ad3bea99420c786be9ef1f92bc71dca22d328bbceeaf6c64b4a3c37161b78318a3e4a424bf247dcb211d7ae29344db2f languageName: node linkType: hard @@ -606,26 +609,26 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/util-user-agent-browser@npm:^3.972.8": - version: 3.972.8 - resolution: "@aws-sdk/util-user-agent-browser@npm:3.972.8" +"@aws-sdk/util-user-agent-browser@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.972.10" dependencies: - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/types": "npm:^4.14.1" bowser: "npm:^2.11.0" tslib: "npm:^2.6.2" - checksum: 10c0/b5153800fab17e3e079c87d0668b65625755c91a47646aabcfc434aad18d6fc0c8921b544a234cd89d11a0b29eef1b73087515438c185ea5bcff75ecb8c2e800 + checksum: 10c0/e139dda2cb51a0a3553c80ec6f89c39cb1292876c116f8449f21a7fdbe39bd7b545ef8ea69a447dd9fa2aa43c99f625ee6a55cbf6d8e6cf2094d0ef00ceb1e36 languageName: node linkType: hard -"@aws-sdk/util-user-agent-node@npm:^3.973.7": - version: 3.973.7 - resolution: "@aws-sdk/util-user-agent-node@npm:3.973.7" +"@aws-sdk/util-user-agent-node@npm:^3.973.22": + version: 3.973.22 + resolution: "@aws-sdk/util-user-agent-node@npm:3.973.22" dependencies: - "@aws-sdk/middleware-user-agent": "npm:^3.972.21" - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/types": "npm:^4.13.1" + "@aws-sdk/middleware-user-agent": "npm:^3.972.36" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/types": "npm:^4.14.1" "@smithy/util-config-provider": "npm:^4.2.2" tslib: "npm:^2.6.2" peerDependencies: @@ -633,18 +636,19 @@ __metadata: peerDependenciesMeta: aws-crt: optional: true - checksum: 10c0/6305796caf8c72279325d6c851a593fe57f02c3159da7292c5afd5048448a483f607c269e7c784c169d6aba066d4d19899937d896d7e8d1013b0f060f575afce + checksum: 10c0/2fdda6636515dd84fcef3ce0009450c7133dd80da287212f574f3543af8a1e1a3fa2df86134d797b6237d8d8a05087390e0d79ec226585531addcbb67831929d languageName: node linkType: hard -"@aws-sdk/xml-builder@npm:^3.972.11": - version: 3.972.11 - resolution: "@aws-sdk/xml-builder@npm:3.972.11" +"@aws-sdk/xml-builder@npm:^3.972.20": + version: 3.972.21 + resolution: "@aws-sdk/xml-builder@npm:3.972.21" dependencies: - "@smithy/types": "npm:^4.13.1" - fast-xml-parser: "npm:5.4.1" + "@nodable/entities": "npm:2.1.0" + "@smithy/types": "npm:^4.14.1" + fast-xml-parser: "npm:5.7.2" tslib: "npm:^2.6.2" - checksum: 10c0/ba933261da8a0ee2d6ee6468229012ee21eb397c7e37848c8a1d8eb029a6ef865c068d0f839158939567fb6b85cca5f77261e92c24bc547e41252358cd9f19f3 + checksum: 10c0/58f0c7a13bd7c67ddf38fedd728b8b78df23997793325811c6980fb2f4dbbe8768be902c0de0527e441e3b23e0d358982d42493a72105f104cab53aab1288886 languageName: node linkType: hard @@ -674,16 +678,7 @@ __metadata: languageName: node linkType: hard -"@gar/promise-retry@npm:^1.0.0": - version: 1.0.2 - resolution: "@gar/promise-retry@npm:1.0.2" - dependencies: - retry: "npm:^0.13.1" - checksum: 10c0/748a84fb0ab962f7867966f21dc24d1872c53c1656dd3352320fe69ad3b2043f2dfdb3be024c7636ce4904c5ba1da22d0f3558e489c3de578f5bb520f062d0fd - languageName: node - linkType: hard - -"@gerrit0/mini-shiki@npm:^3.17.0": +"@gerrit0/mini-shiki@npm:^3.23.0": version: 3.23.0 resolution: "@gerrit0/mini-shiki@npm:3.23.0" dependencies: @@ -719,25 +714,10 @@ __metadata: languageName: node linkType: hard -"@npmcli/agent@npm:^4.0.0": - version: 4.0.0 - resolution: "@npmcli/agent@npm:4.0.0" - dependencies: - agent-base: "npm:^7.1.0" - http-proxy-agent: "npm:^7.0.0" - https-proxy-agent: "npm:^7.0.1" - lru-cache: "npm:^11.2.1" - socks-proxy-agent: "npm:^8.0.3" - checksum: 10c0/f7b5ce0f3dd42c3f8c6546e8433573d8049f67ef11ec22aa4704bc41483122f68bf97752e06302c455ead667af5cb753e6a09bff06632bc465c1cfd4c4b75a53 - languageName: node - linkType: hard - -"@npmcli/fs@npm:^5.0.0": - version: 5.0.0 - resolution: "@npmcli/fs@npm:5.0.0" - dependencies: - semver: "npm:^7.3.5" - checksum: 10c0/26e376d780f60ff16e874a0ac9bc3399186846baae0b6e1352286385ac134d900cc5dafaded77f38d77f86898fc923ae1cee9d7399f0275b1aa24878915d722b +"@nodable/entities@npm:2.1.0, @nodable/entities@npm:^2.1.0": + version: 2.1.0 + resolution: "@nodable/entities@npm:2.1.0" + checksum: 10c0/5a4cba2b61a5b6c726328b18b1de6d033cae4a658a118644bf31e0bcbda126ea7b69385043dc556cf1ed859b9ca220e82b81b5e5c48ef1b519fb8ec104575dee languageName: node linkType: hard @@ -800,16 +780,6 @@ __metadata: languageName: node linkType: hard -"@smithy/abort-controller@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/abort-controller@npm:4.2.12" - dependencies: - "@smithy/types": "npm:^4.13.1" - tslib: "npm:^2.6.2" - checksum: 10c0/839bee519c6bc4cf405395f71a07d0b5b42c22ce1c0163a157a61e18804d5dacd4ade1a3b2b69fea26462eecff4c92593726e96318f16ea8adfb419e7f3dab43 - languageName: node - linkType: hard - "@smithy/chunked-blob-reader-native@npm:^4.2.3": version: 4.2.3 resolution: "@smithy/chunked-blob-reader-native@npm:4.2.3" @@ -829,161 +799,161 @@ __metadata: languageName: node linkType: hard -"@smithy/config-resolver@npm:^4.4.11": - version: 4.4.11 - resolution: "@smithy/config-resolver@npm:4.4.11" +"@smithy/config-resolver@npm:^4.4.17": + version: 4.4.17 + resolution: "@smithy/config-resolver@npm:4.4.17" dependencies: - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/types": "npm:^4.14.1" "@smithy/util-config-provider": "npm:^4.2.2" - "@smithy/util-endpoints": "npm:^3.3.3" - "@smithy/util-middleware": "npm:^4.2.12" + "@smithy/util-endpoints": "npm:^3.4.2" + "@smithy/util-middleware": "npm:^4.2.14" tslib: "npm:^2.6.2" - checksum: 10c0/5769c91e4dc82411bb476836fdc1a6f354c03dbcc8a8693b098655c0aadb4f117b0b58a3f2a2397f55991e24247b810cd0994e691ed62083a419ebe49a1550b0 + checksum: 10c0/44e653e2ed31bf765f0b30ca404dc3e02f30ad800971f812a6363b71da8d37de5d7d8901d9db4d86d2493f25037904f35c01bbb1a83a2a72e7e7b201ce5c411a languageName: node linkType: hard -"@smithy/core@npm:^3.23.11": - version: 3.23.11 - resolution: "@smithy/core@npm:3.23.11" +"@smithy/core@npm:^3.23.17": + version: 3.23.17 + resolution: "@smithy/core@npm:3.23.17" dependencies: - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" - "@smithy/url-parser": "npm:^4.2.12" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" "@smithy/util-base64": "npm:^4.3.2" "@smithy/util-body-length-browser": "npm:^4.2.2" - "@smithy/util-middleware": "npm:^4.2.12" - "@smithy/util-stream": "npm:^4.5.19" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-stream": "npm:^4.5.25" "@smithy/util-utf8": "npm:^4.2.2" "@smithy/uuid": "npm:^1.1.2" tslib: "npm:^2.6.2" - checksum: 10c0/29d796cafcb971172253f937934d55d205ca73a504683cddc68a1f88326e47f68cf0c32b61c8d3071a9ff0a4ee1bafa4600c806f1c9fd343176baf253ba93628 + checksum: 10c0/223631835e93c314a8fa394db724673c0940d3ba9e5ffbd73bd7c09854d7e003d19de950b44ce408e099c72ed3c22eb5e41370abea4ac656f7037e6da07be3c1 languageName: node linkType: hard -"@smithy/credential-provider-imds@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/credential-provider-imds@npm:4.2.12" +"@smithy/credential-provider-imds@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/credential-provider-imds@npm:4.2.14" dependencies: - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/types": "npm:^4.13.1" - "@smithy/url-parser": "npm:^4.2.12" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" tslib: "npm:^2.6.2" - checksum: 10c0/23cadc858a8eb16da9212c7741f53bf92e8ff8bbae0c42194ec076c8cac40b7c2f4e2e2079bfedf5b85384a534876693d7631a27ecae2f4a67af313bb0994869 + checksum: 10c0/62ced0249cb1ba64c6dd98a90b35b93dd9e3f1469d020752c46b5a83ecef38280f4b29c2f63e3b0c414a2fa2ec7631a19370415c80ed4d6ea18a9be040803126 languageName: node linkType: hard -"@smithy/eventstream-codec@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/eventstream-codec@npm:4.2.12" +"@smithy/eventstream-codec@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/eventstream-codec@npm:4.2.14" dependencies: "@aws-crypto/crc32": "npm:5.2.0" - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" "@smithy/util-hex-encoding": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/a593745d2a8b2f23bf6d177db3a91c11b49763cdbc26076b3cde6baeccbba68405a63902d217d31172a8f7dfb16ac44f88fd5e8130271b7d74332b6812d8b9cc + checksum: 10c0/c2f9139004b3f75d3621b21e0ef80f850baf4955cce230e6d18faef171c2b4dc62694b1d86d4000a7ec1babb482afeca666520f69cf31455ed63259da6b2a3ee languageName: node linkType: hard -"@smithy/eventstream-serde-browser@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/eventstream-serde-browser@npm:4.2.12" +"@smithy/eventstream-serde-browser@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/eventstream-serde-browser@npm:4.2.14" dependencies: - "@smithy/eventstream-serde-universal": "npm:^4.2.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/eventstream-serde-universal": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/8eb80511c38f4a2f15248b86ba71abfabda67d9459d3f18e438848719ed8176feb3df071f5869c474ab14687c0beb7f497f2114570cbdfe7969e410184a00dfd + checksum: 10c0/b624cf962ec84bbc61c419938de07548cbff3b1049fe5fb0c88097bdb516e6f3af22f6856ab3a5212cf352e7f1c69b0c5d03370cb4fe7f91a29dff975c385da5 languageName: node linkType: hard -"@smithy/eventstream-serde-config-resolver@npm:^4.3.12": - version: 4.3.12 - resolution: "@smithy/eventstream-serde-config-resolver@npm:4.3.12" +"@smithy/eventstream-serde-config-resolver@npm:^4.3.14": + version: 4.3.14 + resolution: "@smithy/eventstream-serde-config-resolver@npm:4.3.14" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/85fdcf22c19ca16fadfcade3cf53c3ccb75149c726cb94aaa4414da12c89459499107522ee29763a6ce2a3912ec729aa19802b26c4005a06de30b02b2467ea43 + checksum: 10c0/d889a6e12797928c9507e99193f86ba0b7807441b67305643ec6b8b953c54237aa0ae7a4baaf00eb72ff07e3c71e88d6225de3db3d2b33729af38adb81b593f8 languageName: node linkType: hard -"@smithy/eventstream-serde-node@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/eventstream-serde-node@npm:4.2.12" +"@smithy/eventstream-serde-node@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/eventstream-serde-node@npm:4.2.14" dependencies: - "@smithy/eventstream-serde-universal": "npm:^4.2.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/eventstream-serde-universal": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/b775e9d7231afca467442d5d6ba9414fa5734446f02b4277ab274be127a34dd10add79ca845abbb947e6292d1359df6a8c877e7cf4a905f6eb2a18296b3cfd58 + checksum: 10c0/c9dda5011ef3e6565dfa483811567b942fd822dfefb41768213fc9322b5298075c4a9228f8aa745af5bcebb23a2a61e24f091ce2be263da1ca3713aafa89c243 languageName: node linkType: hard -"@smithy/eventstream-serde-universal@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/eventstream-serde-universal@npm:4.2.12" +"@smithy/eventstream-serde-universal@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/eventstream-serde-universal@npm:4.2.14" dependencies: - "@smithy/eventstream-codec": "npm:^4.2.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/eventstream-codec": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/e26efc2e2e0a44e529181a7932bc549ffd26c93393b8b2e5a57054178fb062d92c07318aa6c9ad2f424a1721aec3e791c6439c2aca021e74214f2a58ad1becf6 + checksum: 10c0/82cf563ff67b6543f0981dc3b1ef7fc3b507d5322e611a4f7fde4ae90d1d0eae172298c5bdda3837c5dd5c4d8839d59b72189797e178709be7b1996b3ea71a64 languageName: node linkType: hard -"@smithy/fetch-http-handler@npm:^5.3.15": - version: 5.3.15 - resolution: "@smithy/fetch-http-handler@npm:5.3.15" +"@smithy/fetch-http-handler@npm:^5.3.17": + version: 5.3.17 + resolution: "@smithy/fetch-http-handler@npm:5.3.17" dependencies: - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/querystring-builder": "npm:^4.2.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/querystring-builder": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" "@smithy/util-base64": "npm:^4.3.2" tslib: "npm:^2.6.2" - checksum: 10c0/456f98b8bba5214a01aa9ca73ab4088a529ad6473a72cc74747d676d2c5225748167eb3cddccbc2ef884141965132dab49d19b7599414e899c9c36f71a04ce85 + checksum: 10c0/8adac6bf9d5735f8ddbe9e59dd268f985881b64b03cba7e83401471b3094139189476324fe640bbd19d75f5cd8e098d9a2a7f4925bc9fadecd1ccbe937773c12 languageName: node linkType: hard -"@smithy/hash-blob-browser@npm:^4.2.13": - version: 4.2.13 - resolution: "@smithy/hash-blob-browser@npm:4.2.13" +"@smithy/hash-blob-browser@npm:^4.2.15": + version: 4.2.15 + resolution: "@smithy/hash-blob-browser@npm:4.2.15" dependencies: "@smithy/chunked-blob-reader": "npm:^5.2.2" "@smithy/chunked-blob-reader-native": "npm:^4.2.3" - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/1b28105329b01005f357cc45edefaedc1e49547e2c668da0bd15f42c03b8d0293eece0476d7f1f63d5ea0dc82d73a0964ed066d10c4e43661aa11c2cafbbc238 + checksum: 10c0/0f1ee2fd786306c604c4c08bb3b6ba00bf99fd2ba36743ca5d2695c8d37e02bb84435d5d55ebde6483a506ebcc20c42203750671cdd73618255499ef8cd0852b languageName: node linkType: hard -"@smithy/hash-node@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/hash-node@npm:4.2.12" +"@smithy/hash-node@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/hash-node@npm:4.2.14" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" "@smithy/util-buffer-from": "npm:^4.2.2" "@smithy/util-utf8": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/4da4aaf39d1c2c3eec7a93cd02a055532583238ad3e80247cab211a3490cbff6e1e1a51abfd0502ef98be3f9f416a263c1382f28fad1aff38efaf129ce4b8a3d + checksum: 10c0/c2cfc5dac4f2b996c4c5c503d3c26e52fcd0a647e71541182b24a48925801659828c9d378dad6857444b9d997c1655bdb23f6db5994ad8b7c814b2d0a09655b7 languageName: node linkType: hard -"@smithy/hash-stream-node@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/hash-stream-node@npm:4.2.12" +"@smithy/hash-stream-node@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/hash-stream-node@npm:4.2.14" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" "@smithy/util-utf8": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/481e8aa1a6baf08276203a9738fe8f103b83efe5443f863ec046e82a68f7880cf54c20d4ec61e5e197d39e5adb544a1e020c51554e60882a91f01dc1baf50a53 + checksum: 10c0/db14c0879527dfc0a184a4958e8f229e1e3f15d0e612ad4596ee92de8f9d672a5ead4a325e517f235129530a8d8472c2bb628d0620af6662abc65adfe71f573e languageName: node linkType: hard -"@smithy/invalid-dependency@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/invalid-dependency@npm:4.2.12" +"@smithy/invalid-dependency@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/invalid-dependency@npm:4.2.14" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/688f3c312d07ea72ec98c2a58fdb230bd6b43c122f88a411cb9643c0c6085e2a3a27f36f9c3cc0024b32fa831b4b6353e74933a8f746e18acc09c20ca579384e + checksum: 10c0/deba2c21232050de87e7893b86a27a8f080ace391a3cccf22d7aee183bc3b392247f3bb1a0e4f0b6ea6f928c18ba035fd2ed3af257178ba84f3564d47c635d16 languageName: node linkType: hard @@ -1005,216 +975,216 @@ __metadata: languageName: node linkType: hard -"@smithy/md5-js@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/md5-js@npm:4.2.12" +"@smithy/md5-js@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/md5-js@npm:4.2.14" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" "@smithy/util-utf8": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/f71707c566a007e41cefe616200112673d3fce5408ed464a1ef2fd459af0a4c90da39e8e0f8872eb3146d7b17120d8db017b36ea7b671a0e697eaeca0997e7da + checksum: 10c0/d0bd6f08a3e37e83685fc1eba6621ee0f19576466ba23e7239dc201c97f83d46f6b9f7ddecc48ab74535b1f498d9facc43f18018f168dc26d2b5d0f9c72af761 languageName: node linkType: hard -"@smithy/middleware-content-length@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/middleware-content-length@npm:4.2.12" +"@smithy/middleware-content-length@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/middleware-content-length@npm:4.2.14" dependencies: - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/2800bf2cad2fe6c4eb9edb29e6637b4b937edf89db2a3f95594c93a74ae48144dd1a826712a02a5f3b4e3648a29092f4e573e4828ae88a33b25f87531c329430 + checksum: 10c0/c26cc36504ad9a720e42f009bcc185b16e35ff64644bbec710a998c071d3366face29bb6fa68744adc7312356803666922459e416f3a7ae5c804841957832c3d languageName: node linkType: hard -"@smithy/middleware-endpoint@npm:^4.4.25": - version: 4.4.25 - resolution: "@smithy/middleware-endpoint@npm:4.4.25" +"@smithy/middleware-endpoint@npm:^4.4.32": + version: 4.4.32 + resolution: "@smithy/middleware-endpoint@npm:4.4.32" dependencies: - "@smithy/core": "npm:^3.23.11" - "@smithy/middleware-serde": "npm:^4.2.14" - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/shared-ini-file-loader": "npm:^4.4.7" - "@smithy/types": "npm:^4.13.1" - "@smithy/url-parser": "npm:^4.2.12" - "@smithy/util-middleware": "npm:^4.2.12" + "@smithy/core": "npm:^3.23.17" + "@smithy/middleware-serde": "npm:^4.2.20" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" + "@smithy/util-middleware": "npm:^4.2.14" tslib: "npm:^2.6.2" - checksum: 10c0/bbe55e18a7cc6929646183b8e1ca3a2bdc869ba118af483511c4e8cb5f72f214993fbb63e17ecf4f048389f002c196e609084a1c561b5a25afb871c7d1f70dd7 + checksum: 10c0/a77ba3f56956b872a533754c71f75d2a9d6df9af8d58a059d07caedd1af3ffdcae5b667a0b96b6d067555a777510f6fc5847f699a2dd705e9b5837d21510daa4 languageName: node linkType: hard -"@smithy/middleware-retry@npm:^4.4.42": - version: 4.4.42 - resolution: "@smithy/middleware-retry@npm:4.4.42" +"@smithy/middleware-retry@npm:^4.5.6": + version: 4.5.7 + resolution: "@smithy/middleware-retry@npm:4.5.7" dependencies: - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/service-error-classification": "npm:^4.2.12" - "@smithy/smithy-client": "npm:^4.12.5" - "@smithy/types": "npm:^4.13.1" - "@smithy/util-middleware": "npm:^4.2.12" - "@smithy/util-retry": "npm:^4.2.12" + "@smithy/core": "npm:^3.23.17" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/service-error-classification": "npm:^4.3.1" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-retry": "npm:^4.3.6" "@smithy/uuid": "npm:^1.1.2" tslib: "npm:^2.6.2" - checksum: 10c0/13b61e03585286dbe6f0386b0e65adb0140a308cc3b077e73f711b0993ae219bbfe89ada36bfbec40d1605ba92ae1591d59311a415757589689ab12c97146caa + checksum: 10c0/44ba622d961f83935aa13210fc92edfbf017f655ad4f4fb2024f7e880a70867d547b358be2089966689ed92c5deedcdf4723177c015babaf332ba3b55a40fe9b languageName: node linkType: hard -"@smithy/middleware-serde@npm:^4.2.14": - version: 4.2.14 - resolution: "@smithy/middleware-serde@npm:4.2.14" +"@smithy/middleware-serde@npm:^4.2.20": + version: 4.2.20 + resolution: "@smithy/middleware-serde@npm:4.2.20" dependencies: - "@smithy/core": "npm:^3.23.11" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/core": "npm:^3.23.17" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/fd28180e9f0eb4e040a76896283b1d662257cdfb1f9298836425e434f41569df57f327452d8feeb4bab37aa93891ede10b80c5ea10119a724ca1585ee9264672 + checksum: 10c0/2db736d58c0d628a33febad86259eedd6d025135fc403458e4469a077a6adbb909587408acb62c982fa1b2d8b1376507da90661a4315a544c7d73a62179a3453 languageName: node linkType: hard -"@smithy/middleware-stack@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/middleware-stack@npm:4.2.12" +"@smithy/middleware-stack@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/middleware-stack@npm:4.2.14" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/d06ec807249bb7f13cf4c6ce078a871dbeddb5b0c07536da62942245d2723ec380df4e631ab3c5b3ba7dc9626a609d11fcd48d53c8b0f9b6c9f1239b83d49f40 + checksum: 10c0/5a0323c50b399c4a2ff0d91b219c7c285b006f905f66b291b6013a4e94f14c036ff5a74c565989afc3c6f0fafc6c266bca6746b79e242403713b7d18dc266a92 languageName: node linkType: hard -"@smithy/node-config-provider@npm:^4.3.12": - version: 4.3.12 - resolution: "@smithy/node-config-provider@npm:4.3.12" +"@smithy/node-config-provider@npm:^4.3.14": + version: 4.3.14 + resolution: "@smithy/node-config-provider@npm:4.3.14" dependencies: - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/shared-ini-file-loader": "npm:^4.4.7" - "@smithy/types": "npm:^4.13.1" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/97087669ae1c834bc00ab10ade383a746c411a04788b7104d9f4e921ce7d24c5d77257f9ac8b8c842f886a2d658acd948e133eb95f1ee768cfbe49456441e91c + checksum: 10c0/59033a2fde5327d9ae1a0304a0eb2c3145141024cb4dcb58c5cd3c48371cd3a55d7228a3d2f33a5785b2d6a0a35475cbd0d1b2435d964c824683ac89a664e926 languageName: node linkType: hard -"@smithy/node-http-handler@npm:^4.4.16": - version: 4.4.16 - resolution: "@smithy/node-http-handler@npm:4.4.16" +"@smithy/node-http-handler@npm:^4.6.1": + version: 4.6.1 + resolution: "@smithy/node-http-handler@npm:4.6.1" dependencies: - "@smithy/abort-controller": "npm:^4.2.12" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/querystring-builder": "npm:^4.2.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/querystring-builder": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/f5b0f66a7376d9e878604149f8d00b9267d9b7b329ac9e3e6eeb4bde758814e72a0b43641e6e360a68cca408f0f656e9f6f2798dd26a8ba4de4d8c9c051c1ca8 + checksum: 10c0/6c07fbfd8326cd5369283bd19c1af923ee49b7dcf42fdd199bb7132e4c25eaa6db8573e3b669fb60be0d8f9757a3f2482cd0cf6d6631494255f08239d3036ed2 languageName: node linkType: hard -"@smithy/property-provider@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/property-provider@npm:4.2.12" +"@smithy/property-provider@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/property-provider@npm:4.2.14" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/d4dc0d6c61e3b1f947b1e66074dc527f1a8499fef00627c6e97f01822d357c80db8853a4283d8206075b7fba6b9c59d648dc94ab4b08902acf2a2cb97533dc39 + checksum: 10c0/8d3da90e228b53885d1e82f28b33c095b72f3b1cb5dcd7f7edcd96292d90848e9cdfed85cef00040e198343e850acef61540e4de24bd10b07fbc8e1e8f4a1fdd languageName: node linkType: hard -"@smithy/protocol-http@npm:^5.3.12": - version: 5.3.12 - resolution: "@smithy/protocol-http@npm:5.3.12" +"@smithy/protocol-http@npm:^5.3.14": + version: 5.3.14 + resolution: "@smithy/protocol-http@npm:5.3.14" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/f71f8e54d42637acbef9f01e3974a8ad46187ae020366de4dc84dac7ba8413a8a6fb21369c83b660afa110fc5a56d185c7e48de7d2cf45351ebb1b29aa77962b + checksum: 10c0/ed7306e7e4b919fdacf60d1928f003ac4c4679cffd7472b078547fbed7b6cb9ba524f105b1871c2cd79222871169d3183257fd0a1a81c172fa7cf0a9abaf35f9 languageName: node linkType: hard -"@smithy/querystring-builder@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/querystring-builder@npm:4.2.12" +"@smithy/querystring-builder@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/querystring-builder@npm:4.2.14" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" "@smithy/util-uri-escape": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/171c0d4da2fd024466741e6ee1c05cac5664e0da82c4ac5afd3218278925c25ed00bc3518e02481f4daf3f366034f273fb1cb579f146f10d0edee14dc5676c21 + checksum: 10c0/ae2ceec55b4f32b73fe2eca710563b9dbe65c81157ed58c12c282bad6445998f1fe99d723591f9bac4ae6106d84213b57e960176865fb2dec78fc3bdf024b14b languageName: node linkType: hard -"@smithy/querystring-parser@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/querystring-parser@npm:4.2.12" +"@smithy/querystring-parser@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/querystring-parser@npm:4.2.14" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/be23cd6e68cd14cb2aaa82a06ae92c1202344a91a74f1d0098adaca0cf9e02bc08a112322a56e34873c7a0877445e49b2795ca3e181292239f42b9a2598af068 + checksum: 10c0/245923618197bbae5eb38b72807368f397fafccee8e98cd98ee7d8961a34acb57b5176fa5fe8083b796229043f135ea775060f17e14aa12080a5d7cdfbf56333 languageName: node linkType: hard -"@smithy/service-error-classification@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/service-error-classification@npm:4.2.12" +"@smithy/service-error-classification@npm:^4.3.1": + version: 4.3.1 + resolution: "@smithy/service-error-classification@npm:4.3.1" dependencies: - "@smithy/types": "npm:^4.13.1" - checksum: 10c0/a37ec7bded03f7578473b002bf99771853f9e59ecc53e85fb0501a794b5ff121259225af981f55788ad7adc57ef85ab536de1d2a1c2f5556117426e5485f7da9 + "@smithy/types": "npm:^4.14.1" + checksum: 10c0/1bc927f53693035165f4b15232c644be579cdd432cf2d3e026faa746d26693e6b1e0f35bcd5d812dd89445695fd1eb7188e415e2523d6d8991e8db900cecdf36 languageName: node linkType: hard -"@smithy/shared-ini-file-loader@npm:^4.4.7": - version: 4.4.7 - resolution: "@smithy/shared-ini-file-loader@npm:4.4.7" +"@smithy/shared-ini-file-loader@npm:^4.4.9": + version: 4.4.9 + resolution: "@smithy/shared-ini-file-loader@npm:4.4.9" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/817a1d1b19f7f681ae5972db44416ba215f422da964eda04eae9ed1a31c05ae8ce3bed69c1429c9c42b9d1ec3493933731d2c3ef4b3858431cfdb51aa40b1b93 + checksum: 10c0/9bf96ea80cedff027373c5547ffa53b35d272292b7d142a86211726343061953a34610f3dbd02153bb285c7c0f3802c5a25b4e27870e8068d777abdcaa9c1748 languageName: node linkType: hard -"@smithy/signature-v4@npm:^5.3.12": - version: 5.3.12 - resolution: "@smithy/signature-v4@npm:5.3.12" +"@smithy/signature-v4@npm:^5.3.14": + version: 5.3.14 + resolution: "@smithy/signature-v4@npm:5.3.14" dependencies: "@smithy/is-array-buffer": "npm:^4.2.2" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" "@smithy/util-hex-encoding": "npm:^4.2.2" - "@smithy/util-middleware": "npm:^4.2.12" + "@smithy/util-middleware": "npm:^4.2.14" "@smithy/util-uri-escape": "npm:^4.2.2" "@smithy/util-utf8": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/7163c533c6ffebd93c2f7266b22c0d82488746846e50e795afcb15becd8431cfe993006a99b09828e5905ca56a7ffa6080a3537e092f3a57d661f64c5f0f11a7 + checksum: 10c0/ccc788992e281a681984e079b7194a219af40cf4f7087988eba69c4947264b9a83ac00a806164b9074c7e2f0697e492fa2b377b56b783316d247be02aaccbdb3 languageName: node linkType: hard -"@smithy/smithy-client@npm:^4.12.5": - version: 4.12.5 - resolution: "@smithy/smithy-client@npm:4.12.5" +"@smithy/smithy-client@npm:^4.12.13": + version: 4.12.13 + resolution: "@smithy/smithy-client@npm:4.12.13" dependencies: - "@smithy/core": "npm:^3.23.11" - "@smithy/middleware-endpoint": "npm:^4.4.25" - "@smithy/middleware-stack": "npm:^4.2.12" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/types": "npm:^4.13.1" - "@smithy/util-stream": "npm:^4.5.19" + "@smithy/core": "npm:^3.23.17" + "@smithy/middleware-endpoint": "npm:^4.4.32" + "@smithy/middleware-stack": "npm:^4.2.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-stream": "npm:^4.5.25" tslib: "npm:^2.6.2" - checksum: 10c0/8c5de6c80d4c7b3fb509a605fd5ee1c2ea83eb784264d08b64eb5c893a81ae032c0964afc345956f6b6180f2eaf857dff97730463a19b2a45a781ae89d4fab71 + checksum: 10c0/0680d4d0720d110a637fabb8bdc85175e1471191925f5c55f08636f5327de1bc29c8db75318aac1396491fa83c8a319dd4cd26c5e9fff619207c9a96b9dbd9fe languageName: node linkType: hard -"@smithy/types@npm:^4.13.1": - version: 4.13.1 - resolution: "@smithy/types@npm:4.13.1" +"@smithy/types@npm:^4.14.1": + version: 4.14.1 + resolution: "@smithy/types@npm:4.14.1" dependencies: tslib: "npm:^2.6.2" - checksum: 10c0/775ed9748d9290b8816d933bfb9726eb9301ef2fe9fba1bfbc1966372b9f0d4dd1d3b611aca3c000094bed2ca9d821e10fe2795a75df5bc305bc8845a1e413f7 + checksum: 10c0/9e6209770a25582a11ca67d4750865de0b7732bf3f353ba9260f49e9c4b2adf3274d64057a2bda93da7025e33a54e51bd78c529307efc2b75b429bc45a6ad64c languageName: node linkType: hard -"@smithy/url-parser@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/url-parser@npm:4.2.12" +"@smithy/url-parser@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/url-parser@npm:4.2.14" dependencies: - "@smithy/querystring-parser": "npm:^4.2.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/querystring-parser": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/ff6b127f0bb8ddd6934018277a2ae73ecb036259ec9e0ea4e136da47b39d089ee29ff92fcdbc79613b3c8224f180bcf914289bd71709e9ccc4a444c5f0423086 + checksum: 10c0/360463fe1fb51b8a1c5b80f4a16df993ee4e87221e60ce51c428b0737d6f1325434ec572bb7afca7d65bb9a09c750f307822a23e42be675706ee71fedd7c6c06 languageName: node linkType: hard @@ -1276,41 +1246,41 @@ __metadata: languageName: node linkType: hard -"@smithy/util-defaults-mode-browser@npm:^4.3.41": - version: 4.3.41 - resolution: "@smithy/util-defaults-mode-browser@npm:4.3.41" +"@smithy/util-defaults-mode-browser@npm:^4.3.49": + version: 4.3.49 + resolution: "@smithy/util-defaults-mode-browser@npm:4.3.49" dependencies: - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/smithy-client": "npm:^4.12.5" - "@smithy/types": "npm:^4.13.1" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/b2b028765b49ab5180a7defdd99cf28722917316c9ea6818d2f9d4cfa7a3ac160f75190f3012e91b20d27bd6b60e5751eac829696f496ac5f9612e86cc097539 + checksum: 10c0/0979b09f1108aa41f5bf623e32fa138e129fbffe3adc23c46308afa310b925635428f9d7e36e3e2a312cafe9af325cb5f01667791ee672418d4f302c1961623b languageName: node linkType: hard -"@smithy/util-defaults-mode-node@npm:^4.2.44": - version: 4.2.44 - resolution: "@smithy/util-defaults-mode-node@npm:4.2.44" +"@smithy/util-defaults-mode-node@npm:^4.2.54": + version: 4.2.54 + resolution: "@smithy/util-defaults-mode-node@npm:4.2.54" dependencies: - "@smithy/config-resolver": "npm:^4.4.11" - "@smithy/credential-provider-imds": "npm:^4.2.12" - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/property-provider": "npm:^4.2.12" - "@smithy/smithy-client": "npm:^4.12.5" - "@smithy/types": "npm:^4.13.1" + "@smithy/config-resolver": "npm:^4.4.17" + "@smithy/credential-provider-imds": "npm:^4.2.14" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/00f634beea54b49cde78d42782a395987f5a35841deeec73c06b12cd088dcf1d8cb6f1c42f4df513e9cc68f01e9c8c918152c16fc400563a7fd7bd73a7daaf39 + checksum: 10c0/7146087fa1d7758b37da456719d589f63300843ff5610891de02a0434c5abbd49fd257712c2d9e0bede904f4ec62c9d3c9eddcd6ec0372134f998e4f9c0bce09 languageName: node linkType: hard -"@smithy/util-endpoints@npm:^3.3.3": - version: 3.3.3 - resolution: "@smithy/util-endpoints@npm:3.3.3" +"@smithy/util-endpoints@npm:^3.4.2": + version: 3.4.2 + resolution: "@smithy/util-endpoints@npm:3.4.2" dependencies: - "@smithy/node-config-provider": "npm:^4.3.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/ba80337fa6216e8912d5f78bc192c625807ba212071a8504b40b0bcf2b28d293fbd9b180da1ebcd1d15faf60291a6ff534e288266a29dc9cd600bf5eb1d51579 + checksum: 10c0/602e05c8dc43d8902604bac74b717d09da5b4f1994dcdd5025bffeced6002eaa0612ae1ccbe7a0e636a3d92a60747715e22cbacf8feeb672394d15b6ae211b13 languageName: node linkType: hard @@ -1323,40 +1293,40 @@ __metadata: languageName: node linkType: hard -"@smithy/util-middleware@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/util-middleware@npm:4.2.12" +"@smithy/util-middleware@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/util-middleware@npm:4.2.14" dependencies: - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/0fd7e7e8b5b02023928e7ad27f1c44a312524c393c39aa064c3c371e521035028116a5aa16d8011068b288179eb862bef917d798419b9f2a2843bf4ea3897e2b + checksum: 10c0/6ba5026b70ad7b58fac89d7428c0b1679be2a97a8fb47811a39e0183901a3c6ca8732ee225ddfda28e7b86223d49983ff709421905da571bc00b82c660e4fc27 languageName: node linkType: hard -"@smithy/util-retry@npm:^4.2.12": - version: 4.2.12 - resolution: "@smithy/util-retry@npm:4.2.12" +"@smithy/util-retry@npm:^4.3.5, @smithy/util-retry@npm:^4.3.6": + version: 4.3.6 + resolution: "@smithy/util-retry@npm:4.3.6" dependencies: - "@smithy/service-error-classification": "npm:^4.2.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/service-error-classification": "npm:^4.3.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/1a8bff8da85d6637310286a3a52f557622cc9bb9dc75d9770640701a9565a3a995aeb34ed68acf333f60bb871dc49e9db196c5a35913b33944e02811f3cfcca2 + checksum: 10c0/a2d3b34ca48edd4f58885c91de28f7ccbe48d0e4a8f005eb569197cf37640b5c04043fd556b0e1ecf6d8623b6c384b52b1c34c898acf77260e517f7d00641811 languageName: node linkType: hard -"@smithy/util-stream@npm:^4.5.19": - version: 4.5.19 - resolution: "@smithy/util-stream@npm:4.5.19" +"@smithy/util-stream@npm:^4.5.25": + version: 4.5.25 + resolution: "@smithy/util-stream@npm:4.5.25" dependencies: - "@smithy/fetch-http-handler": "npm:^5.3.15" - "@smithy/node-http-handler": "npm:^4.4.16" - "@smithy/types": "npm:^4.13.1" + "@smithy/fetch-http-handler": "npm:^5.3.17" + "@smithy/node-http-handler": "npm:^4.6.1" + "@smithy/types": "npm:^4.14.1" "@smithy/util-base64": "npm:^4.3.2" "@smithy/util-buffer-from": "npm:^4.2.2" "@smithy/util-hex-encoding": "npm:^4.2.2" "@smithy/util-utf8": "npm:^4.2.2" tslib: "npm:^2.6.2" - checksum: 10c0/bfc4794f629c7da0a87f05beabf3904efcf5cf8e4c21180f776bb2cdc9d75d593558017b060c3573c656ed1dfbbe068e2951c700cf2e76e4efacad8373087a38 + checksum: 10c0/4b222c975eca2018831f1ab4067f122f4ef5e68f3abb71776003309f1a27f67d509a2d86f5f1f81a91656236db0e29c38314c005b17dbf63f053de4d97be7b59 languageName: node linkType: hard @@ -1389,14 +1359,13 @@ __metadata: languageName: node linkType: hard -"@smithy/util-waiter@npm:^4.2.13": - version: 4.2.13 - resolution: "@smithy/util-waiter@npm:4.2.13" +"@smithy/util-waiter@npm:^4.3.0": + version: 4.3.0 + resolution: "@smithy/util-waiter@npm:4.3.0" dependencies: - "@smithy/abort-controller": "npm:^4.2.12" - "@smithy/types": "npm:^4.13.1" + "@smithy/types": "npm:^4.14.1" tslib: "npm:^2.6.2" - checksum: 10c0/02e29879d64214f01e0acf7f9e1157e5aa671371f9e2fb46fc75595e330f785e237c60eba44eb039c8598bfc0fdf3bcb6556742f6631605f71e856f9267524e9 + checksum: 10c0/44e18c946c731740801787714a513e6c119f9997f29191d3bababe9781e0935d08a927caa475d70f7dcb02d70b532639be94317ac3c6305496f1ebe90fcfcf9b languageName: node linkType: hard @@ -1419,6 +1388,7 @@ __metadata: "@types/chai-subset": "npm:^1.3.5" "@types/mocha": "npm:^10.0.0" "@types/node": "npm:^18.0.0" + "@types/node-media-server": "npm:^2" archiver: "npm:^7.0.0" chai: "npm:^4.2.0" chai-subset: "npm:^1.6.0" @@ -1430,6 +1400,7 @@ __metadata: mocha: "npm:^11.0.0" mocha-junit-reporter: "npm:^1.22.0" node-addon-api: "npm:^7.1.1" + node-media-server: "npm:2.7.2" ts-node: "npm:^7.0.1" typedoc: "npm:^0.28.0" typedoc-plugin-markdown: "npm:^4.0.0" @@ -1461,25 +1432,18 @@ __metadata: linkType: hard "@types/chai-subset@npm:^1.3.5": - version: 1.3.5 - resolution: "@types/chai-subset@npm:1.3.5" - dependencies: - "@types/chai": "npm:*" - checksum: 10c0/d5cfb483917b0fdf245c8c51d1fa35a2c302295dfc5383ee4faa545db49a28ea169650bb1b75de2cd31f6f8e486a856d241acf9e0456fc93cb74ac18dfdfd19d - languageName: node - linkType: hard - -"@types/chai@npm:*": - version: 5.0.0 - resolution: "@types/chai@npm:5.0.0" - checksum: 10c0/fcce55f2bbb8485fc860a1dcbac17c1a685b598cfc91a55d37b65b1642b921cf736caa8cce9dcc530830d900f78ab95cf43db4e118db34a5176f252cacd9e1e8 + version: 1.3.6 + resolution: "@types/chai-subset@npm:1.3.6" + peerDependencies: + "@types/chai": <5.2.0 + checksum: 10c0/d8e2362ff7e96b742e00326e656a91fffd6dc1260fd081c30bd607f908eb4d6777cec1e535bae905a8741b8287acded8955c0fe84ee3b6874d38502c029d35c4 languageName: node linkType: hard "@types/chai@npm:^4.1.7": - version: 4.3.11 - resolution: "@types/chai@npm:4.3.11" - checksum: 10c0/0c216ac4a19bfbf8318bb104d32e50704ee2ffc4b538b976c4326e6638fee121462402caa570662227a2a218810388aadb14bdbd3d3d474ec300b00695db448a + version: 4.3.20 + resolution: "@types/chai@npm:4.3.20" + checksum: 10c0/4601189d611752e65018f1ecadac82e94eed29f348e1d5430e5681a60b01e1ecf855d9bcc74ae43b07394751f184f6970fac2b5561fc57a1f36e93a0f5ffb6e8 languageName: node linkType: hard @@ -1493,9 +1457,9 @@ __metadata: linkType: hard "@types/http-cache-semantics@npm:*": - version: 4.0.4 - resolution: "@types/http-cache-semantics@npm:4.0.4" - checksum: 10c0/51b72568b4b2863e0fe8d6ce8aad72a784b7510d72dc866215642da51d84945a9459fa89f49ec48f1e9a1752e6a78e85a4cda0ded06b1c73e727610c925f9ce6 + version: 4.2.0 + resolution: "@types/http-cache-semantics@npm:4.2.0" + checksum: 10c0/82dd33cbe7d4843f1e884a251c6a12d385b62274353b9db167462e7fbffdbb3a83606f9952203017c5b8cabbd7b9eef0cf240a3a9dedd20f69875c9701939415 languageName: node linkType: hard @@ -1515,12 +1479,19 @@ __metadata: languageName: node linkType: hard +"@types/node-media-server@npm:^2": + version: 2.3.7 + resolution: "@types/node-media-server@npm:2.3.7" + checksum: 10c0/666ec4b886ef3d1caeb0c463842a5176c6cd2937ec9a2854cf0b6e205b4307e6a149ec75c215421ffabf81eac0071e72d1848f06dc4271422f8cd2f1bd394a2c + languageName: node + linkType: hard + "@types/node@npm:*": - version: 20.10.5 - resolution: "@types/node@npm:20.10.5" + version: 25.6.0 + resolution: "@types/node@npm:25.6.0" dependencies: - undici-types: "npm:~5.26.4" - checksum: 10c0/be30609aae0bfe492097815f166ccc07f465220cb604647fa4e5ec05a1d16c012a41b82b5f11ecfe2485cbb479d4d20384b95b809ca0bcff6d94d5bbafa645bb + undici-types: "npm:~7.19.0" + checksum: 10c0/d2d2015630ff098a201407f55f5077a20270ae4f465c739b40865cd9933b91b9c5d2b85568eadaf3db0801b91e267333ca7eb39f007428b173d1cdab4b339ac5 languageName: node linkType: hard @@ -1534,11 +1505,11 @@ __metadata: linkType: hard "@types/node@npm:^20.9.0": - version: 20.14.11 - resolution: "@types/node@npm:20.14.11" + version: 20.19.39 + resolution: "@types/node@npm:20.19.39" dependencies: - undici-types: "npm:~5.26.4" - checksum: 10c0/5306becc0ff41d81b1e31524bd376e958d0741d1ce892dffd586b9ae0cb6553c62b0d62abd16da8bea6b9a2c17572d360450535d7c073794b0cef9cb4e39691e + undici-types: "npm:~6.21.0" + checksum: 10c0/1d16da7b5f47a7415b827fcf3b94d279febf4c14671afec74a03e47856b5270023d9beb1b9aeab4d3b622fd97d61a60206cfc2cca588663181331bc592468289 languageName: node linkType: hard @@ -1583,10 +1554,13 @@ __metadata: languageName: node linkType: hard -"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": - version: 7.1.4 - resolution: "agent-base@npm:7.1.4" - checksum: 10c0/c2c9ab7599692d594b6a161559ada307b7a624fa4c7b03e3afdb5a5e31cd0e53269115b620fcab024c5ac6a6f37fa5eb2e004f076ad30f5f7e6b8b671f7b35fe +"accepts@npm:~1.3.8": + version: 1.3.8 + resolution: "accepts@npm:1.3.8" + dependencies: + mime-types: "npm:~2.1.34" + negotiator: "npm:0.6.3" + checksum: 10c0/3a35c5f5586cfb9a21163ca47a5f77ac34fa8ceb5d17d2fa2c0d81f41cbd7f8c6fa52c77e2c039acc0f4d09e71abdc51144246900f6bef5e3c4b333f77d89362 languageName: node linkType: hard @@ -1688,6 +1662,13 @@ __metadata: languageName: node linkType: hard +"array-flatten@npm:1.1.1": + version: 1.1.1 + resolution: "array-flatten@npm:1.1.1" + checksum: 10c0/806966c8abb2f858b08f5324d9d18d7737480610f3bd5d3498aaae6eb5efdc501a884ba019c9b4a8f02ff67002058749d05548fd42fa8643f02c9c7f22198b91 + languageName: node + linkType: hard + "arrify@npm:^1.0.0": version: 1.0.1 resolution: "arrify@npm:1.0.1" @@ -1702,6 +1683,20 @@ __metadata: languageName: node linkType: hard +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73 + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186 + languageName: node + linkType: hard + "async@npm:^3.2.4": version: 3.2.6 resolution: "async@npm:3.2.6" @@ -1748,8 +1743,8 @@ __metadata: linkType: hard "bare-fs@npm:^4.5.5": - version: 4.5.5 - resolution: "bare-fs@npm:4.5.5" + version: 4.7.1 + resolution: "bare-fs@npm:4.7.1" dependencies: bare-events: "npm:^2.5.4" bare-path: "npm:^3.0.0" @@ -1761,14 +1756,14 @@ __metadata: peerDependenciesMeta: bare-buffer: optional: true - checksum: 10c0/1f8b31b73848639fff4ab46fb9d8c0477dc571813fd6790ec75edc192abc467310f1082ecb81170aeffca91b4d08f0e9a002d6f9fa6968a07d11ea22be1597ff + checksum: 10c0/4dc67f6dd0264b817941c2b8cbfc42b6abc3980984cdfd129c4d1f22517cb29f6b99a69fc1e3e87f3a9c997e8c94114604bb67fff10574b2adf0966510cf0222 languageName: node linkType: hard "bare-os@npm:^3.0.1": - version: 3.8.0 - resolution: "bare-os@npm:3.8.0" - checksum: 10c0/2211c5f9734c7d3c387a6ba2ff7fd6df805736a1d9b865a1b9e2ba0904782340ae9b9a58eb359e7e4b50d457a1b656290cc3c8d1628d5df5d95d327eeb3dab63 + version: 3.9.0 + resolution: "bare-os@npm:3.9.0" + checksum: 10c0/fc17ca44a7ae59b9c62531365d6b68fb436f0c77c4c35bb2bbdb9382f2f229f73365164c33350fe1833857e0f4224e3079dc5175cd438067c078d99ccb7932e8 languageName: node linkType: hard @@ -1782,29 +1777,32 @@ __metadata: linkType: hard "bare-stream@npm:^2.6.4": - version: 2.8.1 - resolution: "bare-stream@npm:2.8.1" + version: 2.13.1 + resolution: "bare-stream@npm:2.13.1" dependencies: - streamx: "npm:^2.21.0" + streamx: "npm:^2.25.0" teex: "npm:^1.0.1" peerDependencies: + bare-abort-controller: "*" bare-buffer: "*" bare-events: "*" peerDependenciesMeta: + bare-abort-controller: + optional: true bare-buffer: optional: true bare-events: optional: true - checksum: 10c0/7040f22ee412cfbc0fb13accb6578e77d4e5f2c039de54fd63efda4a6748560c5022827ccdbaf27d493229aec9b1112121b431fa21b79c52cdf9f55ad929c362 + checksum: 10c0/2c35e0b4e56667265e9023e9f51b77652ce043fd6611497575871ce62e833760dd3e5919ccc0cebe1af40959c4350035162b47541a1277d6488709f61f199754 languageName: node linkType: hard "bare-url@npm:^2.2.2": - version: 2.3.2 - resolution: "bare-url@npm:2.3.2" + version: 2.4.2 + resolution: "bare-url@npm:2.4.2" dependencies: bare-path: "npm:^3.0.0" - checksum: 10c0/4fd0046314390a54404519d9db20e130ab3a341ef638d040f9603ae3fa0a1d84f6970357d21c8fc64e6163d1f61fd212cb1cfa4cb537dfead99fb06e3c030b15 + checksum: 10c0/50d225755a0f312e4951ca71538e9401276f73e9a1fbc4419118ff0d1b9ec93fe8c03e1ddbc95672afdc2c9d0ced224a5fb2b15b241759b2649dca572a295c51 languageName: node linkType: hard @@ -1815,6 +1813,15 @@ __metadata: languageName: node linkType: hard +"basic-auth-connect@npm:^1.1.0": + version: 1.1.0 + resolution: "basic-auth-connect@npm:1.1.0" + dependencies: + tsscmp: "npm:^1.0.6" + checksum: 10c0/bd229e1339d9025c6cd08371860160072c97eb0323b79330daae51ef4d7fe9768b46c730779516d835ceb3f6295bc1fd73594100cc295dc2d0405724cdc3a7e6 + languageName: node + linkType: hard + "binary-extensions@npm:^2.0.0": version: 2.3.0 resolution: "binary-extensions@npm:2.3.0" @@ -1822,6 +1829,26 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:~1.20.3": + version: 1.20.5 + resolution: "body-parser@npm:1.20.5" + dependencies: + bytes: "npm:~3.1.2" + content-type: "npm:~1.0.5" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:~1.2.0" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.4.24" + on-finished: "npm:~2.4.1" + qs: "npm:~6.15.1" + raw-body: "npm:~2.5.3" + type-is: "npm:~1.6.18" + unpipe: "npm:~1.0.0" + checksum: 10c0/ad777ca5e4711eae253c93f50fdc4608c60b76a9710d79e5e5b84581c76691e6ad21ecc9158986d9ea2b365df73e403ca33c27a8bccc1a7cfc2ccc248548118d + languageName: node + linkType: hard + "boolean@npm:^3.0.1": version: 3.2.0 resolution: "boolean@npm:3.2.0" @@ -1836,30 +1863,21 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^2.0.1": - version: 2.0.1 - resolution: "brace-expansion@npm:2.0.1" - dependencies: - balanced-match: "npm:^1.0.0" - checksum: 10c0/b358f2fe060e2d7a87aa015979ecea07f3c37d4018f8d6deb5bd4c229ad3a0384fe6029bb76cd8be63c81e516ee52d1a0673edbe2023d53a5191732ae3c3e49f - languageName: node - linkType: hard - -"brace-expansion@npm:^2.0.2": - version: 2.0.2 - resolution: "brace-expansion@npm:2.0.2" +"brace-expansion@npm:^2.0.1, brace-expansion@npm:^2.0.2": + version: 2.1.0 + resolution: "brace-expansion@npm:2.1.0" dependencies: balanced-match: "npm:^1.0.0" - checksum: 10c0/6d117a4c793488af86b83172deb6af143e94c17bc53b0b3cec259733923b4ca84679d506ac261f4ba3c7ed37c46018e2ff442f9ce453af8643ecd64f4a54e6cf + checksum: 10c0/439cedf3e23d7993b37919f1d6fdc653ec21a42437ec3e7460bea9ca8b17edf7a24a633273c31d61aa4335877cf29a443f1871814131c87997a1e6223e1f1502 languageName: node linkType: hard -"brace-expansion@npm:^5.0.2": - version: 5.0.4 - resolution: "brace-expansion@npm:5.0.4" +"brace-expansion@npm:^5.0.5": + version: 5.0.5 + resolution: "brace-expansion@npm:5.0.5" dependencies: balanced-match: "npm:^4.0.2" - checksum: 10c0/359cbcfa80b2eb914ca1f3440e92313fbfe7919ee6b274c35db55bec555aded69dac5ee78f102cec90c35f98c20fa43d10936d0cd9978158823c249257e1643a + checksum: 10c0/4d238e14ed4f5cc9c07285550a41cef23121ca08ba99fa9eb5b55b580dcb6bf868b8210aa10526bdc9f8dc97f33ca2a7259039c4cc131a93042beddb424c48e3 languageName: node linkType: hard @@ -1920,22 +1938,10 @@ __metadata: languageName: node linkType: hard -"cacache@npm:^20.0.1": - version: 20.0.3 - resolution: "cacache@npm:20.0.3" - dependencies: - "@npmcli/fs": "npm:^5.0.0" - fs-minipass: "npm:^3.0.0" - glob: "npm:^13.0.0" - lru-cache: "npm:^11.1.0" - minipass: "npm:^7.0.3" - minipass-collect: "npm:^2.0.1" - minipass-flush: "npm:^1.0.5" - minipass-pipeline: "npm:^1.2.4" - p-map: "npm:^7.0.2" - ssri: "npm:^13.0.0" - unique-filename: "npm:^5.0.0" - checksum: 10c0/c7da1ca694d20e8f8aedabd21dc11518f809a7d2b59aa76a1fc655db5a9e62379e465c157ddd2afe34b19230808882288effa6911b2de26a088a6d5645123462 +"bytes@npm:~3.1.2": + version: 3.1.2 + resolution: "bytes@npm:3.1.2" + checksum: 10c0/76d1c43cbd602794ad8ad2ae94095cddeb1de78c5dddaa7005c51af10b0176c69971a6d88e805a90c2b6550d76636e43c40d8427a808b8645ede885de4a0358e languageName: node linkType: hard @@ -1961,6 +1967,26 @@ __metadata: languageName: node linkType: hard +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 + languageName: node + linkType: hard + +"call-bound@npm:^1.0.2": + version: 1.0.4 + resolution: "call-bound@npm:1.0.4" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + get-intrinsic: "npm:^1.3.0" + checksum: 10c0/f4796a6a0941e71c766aea672f63b72bc61234c4f4964dc6d7606e3664c307e7d77845328a8f3359ce39ddb377fed67318f9ee203dea1d47e46165dcf2917644 + languageName: node + linkType: hard + "camelcase@npm:^6.0.0": version: 6.3.0 resolution: "camelcase@npm:6.3.0" @@ -1976,8 +2002,8 @@ __metadata: linkType: hard "chai@npm:^4.2.0": - version: 4.3.10 - resolution: "chai@npm:4.3.10" + version: 4.5.0 + resolution: "chai@npm:4.5.0" dependencies: assertion-error: "npm:^1.1.0" check-error: "npm:^1.0.3" @@ -1985,12 +2011,12 @@ __metadata: get-func-name: "npm:^2.0.2" loupe: "npm:^2.3.6" pathval: "npm:^1.1.1" - type-detect: "npm:^4.0.8" - checksum: 10c0/c887d24f67be6fb554c7ebbde3bb0568697a8833d475e4768296916891ba143f25fc079f6eb34146f3dd5a3279d34c1f387c32c9a6ab288e579f948d9ccf53fe + type-detect: "npm:^4.1.0" + checksum: 10c0/b8cb596bd1aece1aec659e41a6e479290c7d9bee5b3ad63d2898ad230064e5b47889a3bc367b20100a0853b62e026e2dc514acf25a3c9385f936aa3614d4ab4d languageName: node linkType: hard -"chalk@npm:^4.1.0": +"chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -2118,6 +2144,36 @@ __metadata: languageName: node linkType: hard +"content-disposition@npm:~0.5.4": + version: 0.5.4 + resolution: "content-disposition@npm:0.5.4" + dependencies: + safe-buffer: "npm:5.2.1" + checksum: 10c0/bac0316ebfeacb8f381b38285dc691c9939bf0a78b0b7c2d5758acadad242d04783cee5337ba7d12a565a19075af1b3c11c728e1e4946de73c6ff7ce45f3f1bb + languageName: node + linkType: hard + +"content-type@npm:~1.0.4, content-type@npm:~1.0.5": + version: 1.0.5 + resolution: "content-type@npm:1.0.5" + checksum: 10c0/b76ebed15c000aee4678c3707e0860cb6abd4e680a598c0a26e17f0bfae723ec9cc2802f0ff1bc6e4d80603719010431d2231018373d4dde10f9ccff9dadf5af + languageName: node + linkType: hard + +"cookie-signature@npm:~1.0.6": + version: 1.0.7 + resolution: "cookie-signature@npm:1.0.7" + checksum: 10c0/e7731ad2995ae2efeed6435ec1e22cdd21afef29d300c27281438b1eab2bae04ef0d1a203928c0afec2cee72aa36540b8747406ebe308ad23c8e8cc3c26c9c51 + languageName: node + linkType: hard + +"cookie@npm:~0.7.1": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 + languageName: node + linkType: hard + "core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" @@ -2162,19 +2218,23 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.3.4, debug@npm:^4.3.5": - version: 4.4.3 - resolution: "debug@npm:4.4.3" +"dateformat@npm:^4.6.3": + version: 4.6.3 + resolution: "dateformat@npm:4.6.3" + checksum: 10c0/e2023b905e8cfe2eb8444fb558562b524807a51cdfe712570f360f873271600b5c94aebffaf11efb285e2c072264a7cf243eadb68f3eba0f8cc85fb86cd25df6 + languageName: node + linkType: hard + +"debug@npm:2.6.9, debug@npm:^2.2.0": + version: 2.6.9 + resolution: "debug@npm:2.6.9" dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + ms: "npm:2.0.0" + checksum: 10c0/121908fb839f7801180b69a7e218a40b5a0b718813b886b7d6bdb82001b931c938e2941d1e4450f33a1b1df1da653f5f7a0440c197f29fbf8a6e9d45ff6ef589 languageName: node linkType: hard -"debug@npm:4.3.4, debug@npm:^4.1.0, debug@npm:^4.1.1": +"debug@npm:4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -2186,12 +2246,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:^2.2.0": - version: 2.6.9 - resolution: "debug@npm:2.6.9" +"debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.5": + version: 4.4.3 + resolution: "debug@npm:4.4.3" dependencies: - ms: "npm:2.0.0" - checksum: 10c0/121908fb839f7801180b69a7e218a40b5a0b718813b886b7d6bdb82001b931c938e2941d1e4450f33a1b1df1da653f5f7a0440c197f29fbf8a6e9d45ff6ef589 + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 languageName: node linkType: hard @@ -2212,11 +2275,11 @@ __metadata: linkType: hard "deep-eql@npm:^4.1.3": - version: 4.1.3 - resolution: "deep-eql@npm:4.1.3" + version: 4.1.4 + resolution: "deep-eql@npm:4.1.4" dependencies: type-detect: "npm:^4.0.0" - checksum: 10c0/ff34e8605d8253e1bf9fe48056e02c6f347b81d9b5df1c6650a1b0f6f847b4a86453b16dc226b34f853ef14b626e85d04e081b022e20b00cd7d54f079ce9bbdd + checksum: 10c0/264e0613493b43552fc908f4ff87b8b445c0e6e075656649600e1b8a17a57ee03e960156fce7177646e4d2ddaf8e5ee616d76bd79929ff593e5c79e4e5e6c517 languageName: node linkType: hard @@ -2228,17 +2291,17 @@ __metadata: linkType: hard "define-data-property@npm:^1.0.1": - version: 1.1.1 - resolution: "define-data-property@npm:1.1.1" + version: 1.1.4 + resolution: "define-data-property@npm:1.1.4" dependencies: - get-intrinsic: "npm:^1.2.1" + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" gopd: "npm:^1.0.1" - has-property-descriptors: "npm:^1.0.0" - checksum: 10c0/77ef6e0bceb515e05b5913ab635a84d537cee84f8a7c37c77fdcb31fc5b80f6dbe81b33375e4b67d96aa04e6a0d8d4ea099e431d83f089af8d93adfb584bcb94 + checksum: 10c0/dea0606d1483eb9db8d930d4eac62ca0fa16738b0b3e07046cddfacf7d8c868bbe13fa0cb263eb91c7d0d527960dc3f2f2471a69ed7816210307f6744fe62e37 languageName: node linkType: hard -"define-properties@npm:^1.1.3": +"define-properties@npm:^1.2.1": version: 1.2.1 resolution: "define-properties@npm:1.2.1" dependencies: @@ -2249,6 +2312,20 @@ __metadata: languageName: node linkType: hard +"depd@npm:2.0.0, depd@npm:~2.0.0": + version: 2.0.0 + resolution: "depd@npm:2.0.0" + checksum: 10c0/58bd06ec20e19529b06f7ad07ddab60e504d9e0faca4bd23079fac2d279c3594334d736508dc350e06e510aba5e22e4594483b3a6562ce7c17dd797f4cc4ad2c + languageName: node + linkType: hard + +"destroy@npm:1.2.0, destroy@npm:~1.2.0": + version: 1.2.0 + resolution: "destroy@npm:1.2.0" + checksum: 10c0/bd7633942f57418f5a3b80d5cb53898127bcf53e24cdf5d5f4396be471417671f0fee48a4ebe9a1e9defbde2a31280011af58a57e090ff822f589b443ed4e643 + languageName: node + linkType: hard + "detect-node@npm:^2.0.4": version: 2.1.0 resolution: "detect-node@npm:2.1.0" @@ -2264,9 +2341,9 @@ __metadata: linkType: hard "diff@npm:^3.1.0": - version: 3.5.0 - resolution: "diff@npm:3.5.0" - checksum: 10c0/fc62d5ba9f6d1b8b5833380969037007913d4886997838c247c54ec6934f09ae5a07e17ae28b1f016018149d81df8ad89306f52eac1afa899e0bed49015a64d1 + version: 3.5.1 + resolution: "diff@npm:3.5.1" + checksum: 10c0/28913ba64929561bd91e9aad8b6d5a2766d3c75451b8a2f7b2c0aa19f7edd32842da92ae23ae418a57362cc7a8673a26a9441d6ad896b0c64faf0a54c7ccffe6 languageName: node linkType: hard @@ -2284,6 +2361,17 @@ __metadata: languageName: node linkType: hard +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -2291,6 +2379,13 @@ __metadata: languageName: node linkType: hard +"ee-first@npm:1.1.1": + version: 1.1.1 + resolution: "ee-first@npm:1.1.1" + checksum: 10c0/b5bb125ee93161bc16bfe6e56c6b04de5ad2aa44234d8f644813cc95d861a6910903132b05093706de2b706599367c4130eb6d170f6b46895686b95f87d017b7 + languageName: node + linkType: hard + "electron-mocha@npm:^12.1.0": version: 12.3.1 resolution: "electron-mocha@npm:12.3.1" @@ -2342,12 +2437,19 @@ __metadata: languageName: node linkType: hard +"encodeurl@npm:~2.0.0": + version: 2.0.0 + resolution: "encodeurl@npm:2.0.0" + checksum: 10c0/5d317306acb13e6590e28e27924c754163946a2480de11865c991a3a7eed4315cd3fba378b543ca145829569eefe9b899f3d84bb09870f675ae60bc924b01ceb + languageName: node + linkType: hard + "end-of-stream@npm:^1.1.0": - version: 1.4.4 - resolution: "end-of-stream@npm:1.4.4" + version: 1.4.5 + resolution: "end-of-stream@npm:1.4.5" dependencies: once: "npm:^1.4.0" - checksum: 10c0/870b423afb2d54bb8d243c63e07c170409d41e20b47eeef0727547aea5740bd6717aca45597a9f2745525667a6b804c1e7bede41f856818faee5806dd9ff3975 + checksum: 10c0/b0701c92a10b89afb1cb45bf54a5292c6f008d744eb4382fa559d54775ff31617d1d7bc3ef617575f552e24fad2c7c1a1835948c66b3f3a4be0a6c1f35c883d8 languageName: node linkType: hard @@ -2365,6 +2467,29 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85 + languageName: node + linkType: hard + +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c + languageName: node + linkType: hard + "es6-error@npm:^4.1.1": version: 4.1.1 resolution: "es6-error@npm:4.1.1" @@ -2373,9 +2498,16 @@ __metadata: linkType: hard "escalade@npm:^3.1.1": - version: 3.1.1 - resolution: "escalade@npm:3.1.1" - checksum: 10c0/afd02e6ca91ffa813e1108b5e7756566173d6bc0d1eb951cb44d6b21702ec17c1cf116cfe75d4a2b02e05acb0b808a7a9387d0d1ca5cf9c04ad03a8445c3e46d + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 + languageName: node + linkType: hard + +"escape-html@npm:~1.0.3": + version: 1.0.3 + resolution: "escape-html@npm:1.0.3" + checksum: 10c0/524c739d776b36c3d29fa08a22e03e8824e3b2fd57500e5e44ecf3cc4707c34c60f9ca0781c0e33d191f2991161504c295e98f68c78fe7baa6e57081ec6ac0a3 languageName: node linkType: hard @@ -2386,6 +2518,13 @@ __metadata: languageName: node linkType: hard +"etag@npm:~1.8.1": + version: 1.8.1 + resolution: "etag@npm:1.8.1" + checksum: 10c0/12be11ef62fb9817314d790089a0a49fae4e1b50594135dcb8076312b7d7e470884b5100d249b28c18581b7fd52f8b485689ffae22a11ed9ec17377a33a08f84 + languageName: node + linkType: hard + "event-target-shim@npm:^5.0.0": version: 5.0.1 resolution: "event-target-shim@npm:5.0.1" @@ -2416,6 +2555,45 @@ __metadata: languageName: node linkType: hard +"express@npm:^4.21.1": + version: 4.22.1 + resolution: "express@npm:4.22.1" + dependencies: + accepts: "npm:~1.3.8" + array-flatten: "npm:1.1.1" + body-parser: "npm:~1.20.3" + content-disposition: "npm:~0.5.4" + content-type: "npm:~1.0.4" + cookie: "npm:~0.7.1" + cookie-signature: "npm:~1.0.6" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + finalhandler: "npm:~1.3.1" + fresh: "npm:~0.5.2" + http-errors: "npm:~2.0.0" + merge-descriptors: "npm:1.0.3" + methods: "npm:~1.1.2" + on-finished: "npm:~2.4.1" + parseurl: "npm:~1.3.3" + path-to-regexp: "npm:~0.1.12" + proxy-addr: "npm:~2.0.7" + qs: "npm:~6.14.0" + range-parser: "npm:~1.2.1" + safe-buffer: "npm:5.2.1" + send: "npm:~0.19.0" + serve-static: "npm:~1.16.2" + setprototypeof: "npm:1.2.0" + statuses: "npm:~2.0.1" + type-is: "npm:~1.6.18" + utils-merge: "npm:1.0.1" + vary: "npm:~1.1.2" + checksum: 10c0/ea57f512ab1e05e26b53a14fd432f65a10ec735ece342b37d0b63a7bcb8d337ffbb830ecb8ca15bcdfe423fbff88cea09786277baff200e8cde3ab40faa665cd + languageName: node + linkType: hard + "extract-zip@npm:^2.0.1": version: 2.0.1 resolution: "extract-zip@npm:2.0.1" @@ -2440,24 +2618,26 @@ __metadata: languageName: node linkType: hard -"fast-xml-builder@npm:^1.0.0": - version: 1.1.3 - resolution: "fast-xml-builder@npm:1.1.3" +"fast-xml-builder@npm:^1.1.5": + version: 1.1.5 + resolution: "fast-xml-builder@npm:1.1.5" dependencies: path-expression-matcher: "npm:^1.1.3" - checksum: 10c0/353a2b3695f66f2b0717fcb1a59d2c4108a3b29e7e4f125a51accaccade509dc6fb7e2777da62fb3b339487753ece01eaa270542b10a68dfd43f48d607a6b10e + checksum: 10c0/b814ba5559cb3140de46d2846045607ab4d4c0bfc312a49d22c91efb9f7cd7004971314841e5823eeb467a5bf403e3ade8371b7912200e111df027d42ae51715 languageName: node linkType: hard -"fast-xml-parser@npm:5.4.1": - version: 5.4.1 - resolution: "fast-xml-parser@npm:5.4.1" +"fast-xml-parser@npm:5.7.2": + version: 5.7.2 + resolution: "fast-xml-parser@npm:5.7.2" dependencies: - fast-xml-builder: "npm:^1.0.0" - strnum: "npm:^2.1.2" + "@nodable/entities": "npm:^2.1.0" + fast-xml-builder: "npm:^1.1.5" + path-expression-matcher: "npm:^1.5.0" + strnum: "npm:^2.2.3" bin: fxparser: src/cli/cli.js - checksum: 10c0/8c696438a0c64135faf93ea6a93879208d649b7c9a3293d30d6eb750dc7f766fd083c0df5a82786b60809c3ead64fad155f28dbed25efea91017aaf9f64c91e5 + checksum: 10c0/d48439ce0700add82f5e7c6ccc5a1f06483beb7cd8e88caa83c6406843e52f14988e60d05cbb3a86ffe07e073807674c807e0764d94a280e1c96d7e2011dae8e languageName: node linkType: hard @@ -2491,6 +2671,21 @@ __metadata: languageName: node linkType: hard +"finalhandler@npm:~1.3.1": + version: 1.3.2 + resolution: "finalhandler@npm:1.3.2" + dependencies: + debug: "npm:2.6.9" + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + on-finished: "npm:~2.4.1" + parseurl: "npm:~1.3.3" + statuses: "npm:~2.0.2" + unpipe: "npm:~1.0.0" + checksum: 10c0/435a4fd65e4e4e4c71bb5474980090b73c353a123dd415583f67836bdd6516e528cf07298e219a82b94631dee7830eae5eece38d3c178073cf7df4e8c182f413 + languageName: node + linkType: hard + "find-up@npm:5.0.0, find-up@npm:^5.0.0": version: 5.0.0 resolution: "find-up@npm:5.0.0" @@ -2520,6 +2715,20 @@ __metadata: languageName: node linkType: hard +"forwarded@npm:0.2.0": + version: 0.2.0 + resolution: "forwarded@npm:0.2.0" + checksum: 10c0/9b67c3fac86acdbc9ae47ba1ddd5f2f81526fa4c8226863ede5600a3f7c7416ef451f6f1e240a3cc32d0fd79fcfe6beb08fd0da454f360032bde70bf80afbb33 + languageName: node + linkType: hard + +"fresh@npm:~0.5.2": + version: 0.5.2 + resolution: "fresh@npm:0.5.2" + checksum: 10c0/c6d27f3ed86cc5b601404822f31c900dd165ba63fff8152a3ef714e2012e7535027063bc67ded4cb5b3a49fa596495d46cacd9f47d6328459cf570f08b7d9e5a + languageName: node + linkType: hard + "fs-extra@npm:^8.1.0": version: 8.1.0 resolution: "fs-extra@npm:8.1.0" @@ -2531,15 +2740,6 @@ __metadata: languageName: node linkType: hard -"fs-minipass@npm:^3.0.0": - version: 3.0.3 - resolution: "fs-minipass@npm:3.0.3" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10c0/63e80da2ff9b621e2cb1596abcb9207f1cf82b968b116ccd7b959e3323144cce7fb141462200971c38bbf2ecca51695069db45265705bed09a7cd93ae5b89f94 - languageName: node - linkType: hard - "fs.realpath@npm:^1.0.0": version: 1.0.0 resolution: "fs.realpath@npm:1.0.0" @@ -2573,6 +2773,13 @@ __metadata: languageName: node linkType: hard +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8 + languageName: node + linkType: hard + "get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" @@ -2594,15 +2801,34 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2": - version: 1.2.2 - resolution: "get-intrinsic@npm:1.2.2" - dependencies: +"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.3.0": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" function-bind: "npm:^1.1.2" - has-proto: "npm:^1.0.1" - has-symbols: "npm:^1.0.3" - hasown: "npm:^2.0.0" - checksum: 10c0/4e7fb8adc6172bae7c4fe579569b4d5238b3667c07931cd46b4eee74bbe6ff6b91329bec311a638d8e60f5b51f44fe5445693c6be89ae88d4b5c49f7ff12db0b + generator-function: "npm:^2.0.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d + languageName: node + linkType: hard + +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c languageName: node linkType: hard @@ -2653,17 +2879,6 @@ __metadata: languageName: node linkType: hard -"glob@npm:^13.0.0": - version: 13.0.6 - resolution: "glob@npm:13.0.6" - dependencies: - minimatch: "npm:^10.2.2" - minipass: "npm:^7.1.3" - path-scurry: "npm:^2.0.2" - checksum: 10c0/269c236f11a9b50357fe7a8c6aadac667e01deb5242b19c84975628f05f4438d8ee1354bb62c5d6c10f37fd59911b54d7799730633a2786660d8c69f1d18120a - languageName: node - linkType: hard - "global-agent@npm:^3.0.0": version: 3.0.0 resolution: "global-agent@npm:3.0.0" @@ -2679,20 +2894,19 @@ __metadata: linkType: hard "globalthis@npm:^1.0.1": - version: 1.0.3 - resolution: "globalthis@npm:1.0.3" + version: 1.0.4 + resolution: "globalthis@npm:1.0.4" dependencies: - define-properties: "npm:^1.1.3" - checksum: 10c0/0db6e9af102a5254630351557ac15e6909bc7459d3e3f6b001e59fe784c96d31108818f032d9095739355a88467459e6488ff16584ee6250cd8c27dec05af4b0 + define-properties: "npm:^1.2.1" + gopd: "npm:^1.0.1" + checksum: 10c0/9d156f313af79d80b1566b93e19285f481c591ad6d0d319b4be5e03750d004dde40a39a0f26f7e635f9007a3600802f53ecd85a759b86f109e80a5f705e01846 languageName: node linkType: hard -"gopd@npm:^1.0.1": - version: 1.0.1 - resolution: "gopd@npm:1.0.1" - dependencies: - get-intrinsic: "npm:^1.1.3" - checksum: 10c0/505c05487f7944c552cee72087bf1567debb470d4355b1335f2c262d218ebbff805cd3715448fe29b4b380bae6912561d0467233e4165830efd28da241418c63 +"gopd@npm:^1.0.1, gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead languageName: node linkType: hard @@ -2730,34 +2944,27 @@ __metadata: linkType: hard "has-property-descriptors@npm:^1.0.0": - version: 1.0.1 - resolution: "has-property-descriptors@npm:1.0.1" + version: 1.0.2 + resolution: "has-property-descriptors@npm:1.0.2" dependencies: - get-intrinsic: "npm:^1.2.2" - checksum: 10c0/d62ba94b40150b00d621bc64a6aedb5bf0ee495308b4b7ed6bac856043db3cdfb1db553ae81cec91c9d2bd82057ff0e94145e7fa25d5aa5985ed32e0921927f6 - languageName: node - linkType: hard - -"has-proto@npm:^1.0.1": - version: 1.0.1 - resolution: "has-proto@npm:1.0.1" - checksum: 10c0/c8a8fe411f810b23a564bd5546a8f3f0fff6f1b692740eb7a2fdc9df716ef870040806891e2f23ff4653f1083e3895bf12088703dd1a0eac3d9202d3a4768cd0 + es-define-property: "npm:^1.0.0" + checksum: 10c0/253c1f59e80bb476cf0dde8ff5284505d90c3bdb762983c3514d36414290475fe3fd6f574929d84de2a8eec00d35cf07cb6776205ff32efd7c50719125f00236 languageName: node linkType: hard -"has-symbols@npm:^1.0.3": - version: 1.0.3 - resolution: "has-symbols@npm:1.0.3" - checksum: 10c0/e6922b4345a3f37069cdfe8600febbca791c94988c01af3394d86ca3360b4b93928bbf395859158f88099cb10b19d98e3bbab7c9ff2c1bd09cf665ee90afa2c3 +"has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e languageName: node linkType: hard -"hasown@npm:^2.0.0": - version: 2.0.0 - resolution: "hasown@npm:2.0.0" +"hasown@npm:^2.0.2": + version: 2.0.3 + resolution: "hasown@npm:2.0.3" dependencies: function-bind: "npm:^1.1.2" - checksum: 10c0/5d415b114f410661208c95e7ab4879f1cc2765b8daceff4dc8718317d1cb7b9ffa7c5d1eafd9a4389c9aab7445d6ea88e05f3096cb1e529618b55304956b87fc + checksum: 10c0/f5eb28c3fd0d3e4facd821c1eeee3836c37b70ab0b0fc532e8a39976e18fef43652415dadc52f8c7a5ff6d5ac93b7bef128789aa6f90f4e9b9a9083dce74ab38 languageName: node linkType: hard @@ -2771,26 +2978,29 @@ __metadata: linkType: hard "http-cache-semantics@npm:^4.0.0": - version: 4.1.1 - resolution: "http-cache-semantics@npm:4.1.1" - checksum: 10c0/ce1319b8a382eb3cbb4a37c19f6bfe14e5bb5be3d09079e885e8c513ab2d3cd9214902f8a31c9dc4e37022633ceabfc2d697405deeaf1b8f3552bb4ed996fdfc - languageName: node - linkType: hard - -"http-cache-semantics@npm:^4.1.1": version: 4.2.0 resolution: "http-cache-semantics@npm:4.2.0" checksum: 10c0/45b66a945cf13ec2d1f29432277201313babf4a01d9e52f44b31ca923434083afeca03f18417f599c9ab3d0e7b618ceb21257542338b57c54b710463b4a53e37 languageName: node linkType: hard -"http-proxy-agent@npm:^7.0.0": - version: 7.0.2 - resolution: "http-proxy-agent@npm:7.0.2" +"http-errors@npm:~2.0.0, http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" dependencies: - agent-base: "npm:^7.1.0" - debug: "npm:^4.3.4" - checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921 + depd: "npm:~2.0.0" + inherits: "npm:~2.0.4" + setprototypeof: "npm:~1.2.0" + statuses: "npm:~2.0.2" + toidentifier: "npm:~1.0.1" + checksum: 10c0/fb38906cef4f5c83952d97661fe14dc156cb59fe54812a42cd448fa57b5c5dfcb38a40a916957737bd6b87aab257c0648d63eb5b6a9ca9f548e105b6072712d4 + languageName: node + linkType: hard + +"http2-express@npm:^1.0.0": + version: 1.1.0 + resolution: "http2-express@npm:1.1.0" + checksum: 10c0/a2f11b474f48cc8e95d7e786f9f5dccaeeb23aa5467b863403fc85ecd7e0159742c78e456a1189879b3b43f469da6e8b71b54c7d61011d1602828522e9a1eda6 languageName: node linkType: hard @@ -2804,22 +3014,12 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^7.0.1": - version: 7.0.6 - resolution: "https-proxy-agent@npm:7.0.6" - dependencies: - agent-base: "npm:^7.1.2" - debug: "npm:4" - checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac - languageName: node - linkType: hard - -"iconv-lite@npm:^0.7.2": - version: 0.7.2 - resolution: "iconv-lite@npm:0.7.2" +"iconv-lite@npm:~0.4.24": + version: 0.4.24 + resolution: "iconv-lite@npm:0.4.24" dependencies: - safer-buffer: "npm:>= 2.1.2 < 3.0.0" - checksum: 10c0/3c228920f3bd307f56bf8363706a776f4a060eb042f131cd23855ceca962951b264d0997ab38a1ad340e1c5df8499ed26e1f4f0db6b2a2ad9befaff22f14b722 + safer-buffer: "npm:>= 2.1.2 < 3" + checksum: 10c0/c6886a24cc00f2a059767440ec1bc00d334a89f250db8e0f7feb4961c8727118457e27c495ba94d082e51d3baca378726cd110aaf7ded8b9bbfd6a44760cf1d4 languageName: node linkType: hard @@ -2830,13 +3030,6 @@ __metadata: languageName: node linkType: hard -"imurmurhash@npm:^0.1.4": - version: 0.1.4 - resolution: "imurmurhash@npm:0.1.4" - checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6 - languageName: node - linkType: hard - "inflight@npm:^1.0.4": version: 1.0.6 resolution: "inflight@npm:1.0.6" @@ -2854,10 +3047,10 @@ __metadata: languageName: node linkType: hard -"ip-address@npm:^10.0.1": - version: 10.1.0 - resolution: "ip-address@npm:10.1.0" - checksum: 10c0/0103516cfa93f6433b3bd7333fa876eb21263912329bfa47010af5e16934eeeff86f3d2ae700a3744a137839ddfad62b900c7a445607884a49b5d1e32a3d7566 +"ipaddr.js@npm:1.9.1": + version: 1.9.1 + resolution: "ipaddr.js@npm:1.9.1" + checksum: 10c0/0486e775047971d3fdb5fb4f063829bac45af299ae0b82dcf3afa2145338e08290563a2a70f34b732d795ecc8311902e541a8530eeb30d75860a78ff4e94ce2a languageName: node linkType: hard @@ -2957,9 +3150,9 @@ __metadata: linkType: hard "isexe@npm:^3.1.1": - version: 3.1.1 - resolution: "isexe@npm:3.1.1" - checksum: 10c0/9ec257654093443eb0a528a9c8cbba9c0ca7616ccb40abd6dde7202734d96bb86e4ac0d764f0f8cd965856aacbff2f4ce23e730dc19dfb41e3b0d865ca6fdcc7 + version: 3.1.5 + resolution: "isexe@npm:3.1.5" + checksum: 10c0/8be2973a09f2f804ea1f34bfccfd5ea219ef48083bdb12107fe5bcf96b3e36b85084409e1b09ddaf2fae8927fdd9f6d70d90baadb78caa1ca7c530935706c8a4 languageName: node linkType: hard @@ -3067,10 +3260,10 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.15": - version: 4.17.21 - resolution: "lodash@npm:4.17.21" - checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c +"lodash@npm:^4.17.15, lodash@npm:^4.17.21": + version: 4.18.1 + resolution: "lodash@npm:4.18.1" + checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27 languageName: node linkType: hard @@ -3107,22 +3300,6 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1": - version: 11.2.7 - resolution: "lru-cache@npm:11.2.7" - checksum: 10c0/549cdb59488baa617135fc12159cafb1a97f91079f35093bb3bcad72e849fc64ace636d244212c181dfdf1a99bbfa90757ff303f98561958ee4d0f885d9bd5f7 - languageName: node - linkType: hard - -"lru-cache@npm:^6.0.0": - version: 6.0.0 - resolution: "lru-cache@npm:6.0.0" - dependencies: - yallist: "npm:^4.0.0" - checksum: 10c0/cb53e582785c48187d7a188d3379c181b5ca2a9c78d2bce3e7dee36f32761d1c42983da3fe12b55cb74e1779fa94cdc2e5367c028a9b35317184ede0c07a30a9 - languageName: node - linkType: hard - "lunr@npm:^2.3.9": version: 2.3.9 resolution: "lunr@npm:2.3.9" @@ -3137,26 +3314,7 @@ __metadata: languageName: node linkType: hard -"make-fetch-happen@npm:^15.0.0": - version: 15.0.4 - resolution: "make-fetch-happen@npm:15.0.4" - dependencies: - "@gar/promise-retry": "npm:^1.0.0" - "@npmcli/agent": "npm:^4.0.0" - cacache: "npm:^20.0.1" - http-cache-semantics: "npm:^4.1.1" - minipass: "npm:^7.0.2" - minipass-fetch: "npm:^5.0.0" - minipass-flush: "npm:^1.0.5" - minipass-pipeline: "npm:^1.2.4" - negotiator: "npm:^1.0.0" - proc-log: "npm:^6.0.0" - ssri: "npm:^13.0.0" - checksum: 10c0/b874bf6879fc0b8ef3a3cafdddadea4d956acf94790f8dede1a9d3c74c7886b6cd3eb992616b8e5935e6fd550016a465f10ba51bf6723a0c6f4d98883ae2926b - languageName: node - linkType: hard - -"markdown-it@npm:^14.1.0": +"markdown-it@npm:^14.1.1": version: 14.1.1 resolution: "markdown-it@npm:14.1.1" dependencies: @@ -3181,6 +3339,13 @@ __metadata: languageName: node linkType: hard +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f + languageName: node + linkType: hard + "md5@npm:^2.1.0": version: 2.3.0 resolution: "md5@npm:2.3.0" @@ -3199,6 +3364,52 @@ __metadata: languageName: node linkType: hard +"media-typer@npm:0.3.0": + version: 0.3.0 + resolution: "media-typer@npm:0.3.0" + checksum: 10c0/d160f31246907e79fed398470285f21bafb45a62869dc469b1c8877f3f064f5eabc4bcc122f9479b8b605bc5c76187d7871cf84c4ee3ecd3e487da1993279928 + languageName: node + linkType: hard + +"merge-descriptors@npm:1.0.3": + version: 1.0.3 + resolution: "merge-descriptors@npm:1.0.3" + checksum: 10c0/866b7094afd9293b5ea5dcd82d71f80e51514bed33b4c4e9f516795dc366612a4cbb4dc94356e943a8a6914889a914530badff27f397191b9b75cda20b6bae93 + languageName: node + linkType: hard + +"methods@npm:~1.1.2": + version: 1.1.2 + resolution: "methods@npm:1.1.2" + checksum: 10c0/bdf7cc72ff0a33e3eede03708c08983c4d7a173f91348b4b1e4f47d4cdbf734433ad971e7d1e8c77247d9e5cd8adb81ea4c67b0a2db526b758b2233d7814b8b2 + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"mime@npm:1.6.0": + version: 1.6.0 + resolution: "mime@npm:1.6.0" + bin: + mime: cli.js + checksum: 10c0/b92cd0adc44888c7135a185bfd0dddc42c32606401c72896a842ae15da71eb88858f17669af41e498b463cd7eb998f7b48939a25b08374c7924a9c8a6f8a81b0 + languageName: node + linkType: hard + "mimic-response@npm:^1.0.0": version: 1.0.1 resolution: "mimic-response@npm:1.0.1" @@ -3222,12 +3433,12 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^10.2.2": - version: 10.2.4 - resolution: "minimatch@npm:10.2.4" +"minimatch@npm:^10.2.5": + version: 10.2.5 + resolution: "minimatch@npm:10.2.5" dependencies: - brace-expansion: "npm:^5.0.2" - checksum: 10c0/35f3dfb7b99b51efd46afd378486889f590e7efb10e0f6a10ba6800428cf65c9a8dedb74427d0570b318d749b543dc4e85f06d46d2858bc8cac7e1eb49a95945 + brace-expansion: "npm:^5.0.5" + checksum: 10c0/6bb058bd6324104b9ec2f763476a35386d05079c1f5fe4fbf1f324a25237cd4534d6813ecd71f48208f4e635c1221899bef94c3c89f7df55698fe373aaae20fd languageName: node linkType: hard @@ -3249,81 +3460,21 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0, minimist@npm:^1.2.6": +"minimist@npm:^1.2.0, minimist@npm:^1.2.6, minimist@npm:^1.2.8": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 languageName: node linkType: hard -"minipass-collect@npm:^2.0.1": - version: 2.0.1 - resolution: "minipass-collect@npm:2.0.1" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10c0/5167e73f62bb74cc5019594709c77e6a742051a647fe9499abf03c71dca75515b7959d67a764bdc4f8b361cf897fbf25e2d9869ee039203ed45240f48b9aa06e - languageName: node - linkType: hard - -"minipass-fetch@npm:^5.0.0": - version: 5.0.2 - resolution: "minipass-fetch@npm:5.0.2" - dependencies: - iconv-lite: "npm:^0.7.2" - minipass: "npm:^7.0.3" - minipass-sized: "npm:^2.0.0" - minizlib: "npm:^3.0.1" - dependenciesMeta: - iconv-lite: - optional: true - checksum: 10c0/ce4ab9f21cfabaead2097d95dd33f485af8072fbc6b19611bce694965393453a1639d641c2bcf1c48f2ea7d41ea7fab8278373f1d0bee4e63b0a5b2cdd0ef649 - languageName: node - linkType: hard - -"minipass-flush@npm:^1.0.5": - version: 1.0.5 - resolution: "minipass-flush@npm:1.0.5" - dependencies: - minipass: "npm:^3.0.0" - checksum: 10c0/2a51b63feb799d2bb34669205eee7c0eaf9dce01883261a5b77410c9408aa447e478efd191b4de6fc1101e796ff5892f8443ef20d9544385819093dbb32d36bd - languageName: node - linkType: hard - -"minipass-pipeline@npm:^1.2.4": - version: 1.2.4 - resolution: "minipass-pipeline@npm:1.2.4" - dependencies: - minipass: "npm:^3.0.0" - checksum: 10c0/cbda57cea20b140b797505dc2cac71581a70b3247b84480c1fed5ca5ba46c25ecc25f68bfc9e6dcb1a6e9017dab5c7ada5eab73ad4f0a49d84e35093e0c643f2 - languageName: node - linkType: hard - -"minipass-sized@npm:^2.0.0": - version: 2.0.0 - resolution: "minipass-sized@npm:2.0.0" - dependencies: - minipass: "npm:^7.1.2" - checksum: 10c0/f9201696a6f6d68610d04c9c83e3d2e5cb9c026aae1c8cbf7e17f386105cb79c1bb088dbc21bf0b1eb4f3fb5df384fd1e7aa3bf1f33868c416ae8c8a92679db8 - languageName: node - linkType: hard - -"minipass@npm:^3.0.0": - version: 3.3.6 - resolution: "minipass@npm:3.3.6" - dependencies: - yallist: "npm:^4.0.0" - checksum: 10c0/a114746943afa1dbbca8249e706d1d38b85ed1298b530f5808ce51f8e9e941962e2a5ad2e00eae7dd21d8a4aae6586a66d4216d1a259385e9d0358f0c1eba16c - languageName: node - linkType: hard - -"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3": +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.3 resolution: "minipass@npm:7.1.3" checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb languageName: node linkType: hard -"minizlib@npm:^3.0.1, minizlib@npm:^3.1.0": +"minizlib@npm:^3.1.0": version: 3.1.0 resolution: "minizlib@npm:3.1.0" dependencies: @@ -3343,6 +3494,15 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:^2.1.6": + version: 2.1.6 + resolution: "mkdirp@npm:2.1.6" + bin: + mkdirp: dist/cjs/src/bin.js + checksum: 10c0/96f551c651dd8f5f9435d53df1a7b9bfc553be769ee6da5192c37c1f303a376ef1c6996f96913d4a8d357060451d4526a346031d1919f92c58806a5fa3cd8dfe + languageName: node + linkType: hard + "mocha-junit-reporter@npm:^1.22.0": version: 1.23.3 resolution: "mocha-junit-reporter@npm:1.23.3" @@ -3442,10 +3602,10 @@ __metadata: languageName: node linkType: hard -"negotiator@npm:^1.0.0": - version: 1.0.0 - resolution: "negotiator@npm:1.0.0" - checksum: 10c0/4c559dd52669ea48e1914f9d634227c561221dd54734070791f999c52ed0ff36e437b2e07d5c1f6e32909fc625fe46491c16e4a8f0572567d4dd15c3a4fda04b +"negotiator@npm:0.6.3": + version: 0.6.3 + resolution: "negotiator@npm:0.6.3" + checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2 languageName: node linkType: hard @@ -3459,22 +3619,41 @@ __metadata: linkType: hard "node-gyp@npm:latest": - version: 12.2.0 - resolution: "node-gyp@npm:12.2.0" + version: 12.3.0 + resolution: "node-gyp@npm:12.3.0" dependencies: env-paths: "npm:^2.2.0" exponential-backoff: "npm:^3.1.1" graceful-fs: "npm:^4.2.6" - make-fetch-happen: "npm:^15.0.0" nopt: "npm:^9.0.0" proc-log: "npm:^6.0.0" semver: "npm:^7.3.5" tar: "npm:^7.5.4" tinyglobby: "npm:^0.2.12" + undici: "npm:^6.25.0" which: "npm:^6.0.0" bin: node-gyp: bin/node-gyp.js - checksum: 10c0/3ed046746a5a7d90950cd8b0547332b06598443f31fe213ef4332a7174c7b7d259e1704835feda79b87d3f02e59d7791842aac60642ede4396ab25fdf0f8f759 + checksum: 10c0/9d9032b405cbe42f72a105259d9eb679376470c102df4a2dbaa51e07d59bf741dcffb85897087ea9d8318b9cabb824a8978af51508ae142f0239ae1e6a3c2329 + languageName: node + linkType: hard + +"node-media-server@npm:2.7.2": + version: 2.7.2 + resolution: "node-media-server@npm:2.7.2" + dependencies: + basic-auth-connect: "npm:^1.1.0" + chalk: "npm:^4.1.2" + dateformat: "npm:^4.6.3" + express: "npm:^4.21.1" + http2-express: "npm:^1.0.0" + lodash: "npm:^4.17.21" + minimist: "npm:^1.2.8" + mkdirp: "npm:^2.1.6" + ws: "npm:^8.18.0" + bin: + node-media-server: bin/app.js + checksum: 10c0/e14a5c51a65a7d643b532e8e79fd92148a85fe3612964a029b92d07c8719a4bb441d0437e74e66df6c5980aedf7bfb83a3d3ed00dc5649b6e175cd77b614ad10 languageName: node linkType: hard @@ -3503,6 +3682,13 @@ __metadata: languageName: node linkType: hard +"object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": + version: 1.13.4 + resolution: "object-inspect@npm:1.13.4" + checksum: 10c0/d7f8711e803b96ea3191c745d6f8056ce1f2496e530e6a19a0e92d89b0fa3c76d910c31f0aa270432db6bd3b2f85500a376a83aaba849a8d518c8845b3211692 + languageName: node + linkType: hard + "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -3510,6 +3696,15 @@ __metadata: languageName: node linkType: hard +"on-finished@npm:~2.4.1": + version: 2.4.1 + resolution: "on-finished@npm:2.4.1" + dependencies: + ee-first: "npm:1.1.1" + checksum: 10c0/46fb11b9063782f2d9968863d9cbba33d77aa13c17f895f56129c274318b86500b22af3a160fe9995aa41317efcd22941b6eba747f718ced08d9a73afdb087b4 + languageName: node + linkType: hard + "once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -3544,13 +3739,6 @@ __metadata: languageName: node linkType: hard -"p-map@npm:^7.0.2": - version: 7.0.4 - resolution: "p-map@npm:7.0.4" - checksum: 10c0/a5030935d3cb2919d7e89454d1ce82141e6f9955413658b8c9403cfe379283770ed3048146b44cde168aa9e8c716505f196d5689db0ae3ce9a71521a2fef3abd - languageName: node - linkType: hard - "package-json-from-dist@npm:^1.0.0": version: 1.0.1 resolution: "package-json-from-dist@npm:1.0.1" @@ -3558,6 +3746,13 @@ __metadata: languageName: node linkType: hard +"parseurl@npm:~1.3.3": + version: 1.3.3 + resolution: "parseurl@npm:1.3.3" + checksum: 10c0/90dd4760d6f6174adb9f20cf0965ae12e23879b5f5464f38e92fce8073354341e4b3b76fa3d878351efe7d01e617121955284cfd002ab087fba1a0726ec0b4f5 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -3565,10 +3760,10 @@ __metadata: languageName: node linkType: hard -"path-expression-matcher@npm:^1.1.3": - version: 1.1.3 - resolution: "path-expression-matcher@npm:1.1.3" - checksum: 10c0/45c01471bc62c5f38d069418aec831763e6f45bb85f9520b08de441e6cd14f84b3098ecb66255e819c2af21102abcd2b45550dc1285996717ce9292802df2bc5 +"path-expression-matcher@npm:^1.1.3, path-expression-matcher@npm:^1.5.0": + version: 1.5.0 + resolution: "path-expression-matcher@npm:1.5.0" + checksum: 10c0/646cb5bc66cd7d809a52288336f3ac1e6223f156fd8e912936e490e590f7f93e8056d4fd25fcbcc7da61bb698fa520112cb050372a3f65e7b79bd4afa0f77610 languageName: node linkType: hard @@ -3589,13 +3784,10 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^2.0.2": - version: 2.0.2 - resolution: "path-scurry@npm:2.0.2" - dependencies: - lru-cache: "npm:^11.0.0" - minipass: "npm:^7.1.2" - checksum: 10c0/b35ad37cf6557a87fd057121ce2be7695380c9138d93e87ae928609da259ea0a170fac6f3ef1eb3ece8a068e8b7f2f3adf5bb2374cf4d4a57fe484954fcc9482 +"path-to-regexp@npm:~0.1.12": + version: 0.1.13 + resolution: "path-to-regexp@npm:0.1.13" + checksum: 10c0/1cae3921739c154a8926e136185a10c916f79a249b9072a5001b266d96e193860ca03867e8e8cc808b786862d750f427ed93686bc259355442c3407a62deab1a languageName: node linkType: hard @@ -3621,16 +3813,16 @@ __metadata: linkType: hard "picomatch@npm:^2.0.4, picomatch@npm:^2.2.1": - version: 2.3.1 - resolution: "picomatch@npm:2.3.1" - checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be + version: 2.3.2 + resolution: "picomatch@npm:2.3.2" + checksum: 10c0/a554d1709e59be97d1acb9eaedbbc700a5c03dbd4579807baed95100b00420bc729335440ef15004ae2378984e2487a7c1cebd743cfdb72b6fa9ab69223c0d61 languageName: node linkType: hard -"picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 +"picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 languageName: node linkType: hard @@ -3662,13 +3854,23 @@ __metadata: languageName: node linkType: hard +"proxy-addr@npm:~2.0.7": + version: 2.0.7 + resolution: "proxy-addr@npm:2.0.7" + dependencies: + forwarded: "npm:0.2.0" + ipaddr.js: "npm:1.9.1" + checksum: 10c0/c3eed999781a35f7fd935f398b6d8920b6fb00bbc14287bc6de78128ccc1a02c89b95b56742bf7cf0362cc333c61d138532049c7dedc7a328ef13343eff81210 + languageName: node + linkType: hard + "pump@npm:^3.0.0": - version: 3.0.0 - resolution: "pump@npm:3.0.0" + version: 3.0.4 + resolution: "pump@npm:3.0.4" dependencies: end-of-stream: "npm:^1.1.0" once: "npm:^1.3.1" - checksum: 10c0/bbdeda4f747cdf47db97428f3a135728669e56a0ae5f354a9ac5b74556556f5446a46f720a8f14ca2ece5be9b4d5d23c346db02b555f46739934cc6c093a5478 + checksum: 10c0/2780e66b5471c19e3e3e1063b84f3f6a3a08367f24c5ed552f98cd5901e6ada27c7ad6495d4244f553fd03b01884a4561933064f053f47c8994d84fd352768ea languageName: node linkType: hard @@ -3679,6 +3881,24 @@ __metadata: languageName: node linkType: hard +"qs@npm:~6.14.0": + version: 6.14.2 + resolution: "qs@npm:6.14.2" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10c0/646110124476fc9acf3c80994c8c3a0600cbad06a4ede1c9e93341006e8426d64e85e048baf8f0c4995f0f1bf0f37d1f3acc5ec1455850b81978792969a60ef6 + languageName: node + linkType: hard + +"qs@npm:~6.15.1": + version: 6.15.1 + resolution: "qs@npm:6.15.1" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10c0/19ee504f0ebff72598503e38cd6d9bd7b52a8ab62ae18b1e6bee3d4db58469bd65871ef1893a881bafb0f80ef2f9ab586e1f255cf25cc8d816c0f5a704721d97 + languageName: node + linkType: hard + "quick-lru@npm:^5.1.1": version: 5.1.1 resolution: "quick-lru@npm:5.1.1" @@ -3695,6 +3915,25 @@ __metadata: languageName: node linkType: hard +"range-parser@npm:~1.2.1": + version: 1.2.1 + resolution: "range-parser@npm:1.2.1" + checksum: 10c0/96c032ac2475c8027b7a4e9fe22dc0dfe0f6d90b85e496e0f016fbdb99d6d066de0112e680805075bd989905e2123b3b3d002765149294dce0c1f7f01fcc2ea0 + languageName: node + linkType: hard + +"raw-body@npm:~2.5.3": + version: 2.5.3 + resolution: "raw-body@npm:2.5.3" + dependencies: + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.4.24" + unpipe: "npm:~1.0.0" + checksum: 10c0/449844344fc90547fb994383a494b83300e4f22199f146a79f68d78a199a8f2a923ea9fd29c3be979bfd50291a3884733619ffc15ba02a32e703b612f8d3f74a + languageName: node + linkType: hard + "readable-stream@npm:^2.0.5": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" @@ -3782,13 +4021,6 @@ __metadata: languageName: node linkType: hard -"retry@npm:^0.13.1": - version: 0.13.1 - resolution: "retry@npm:0.13.1" - checksum: 10c0/9ae822ee19db2163497e074ea919780b1efa00431d197c7afdb950e42bf109196774b92a49fc9821f0b8b328a98eea6017410bfc5e8a0fc19c85c6d11adb3772 - languageName: node - linkType: hard - "roarr@npm:^2.15.3": version: 2.15.4 resolution: "roarr@npm:2.15.4" @@ -3803,7 +4035,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -3817,7 +4049,7 @@ __metadata: languageName: node linkType: hard -"safer-buffer@npm:>= 2.1.2 < 3.0.0": +"safer-buffer@npm:>= 2.1.2 < 3": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 @@ -3840,18 +4072,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.2": - version: 7.5.4 - resolution: "semver@npm:7.5.4" - dependencies: - lru-cache: "npm:^6.0.0" - bin: - semver: bin/semver.js - checksum: 10c0/5160b06975a38b11c1ab55950cb5b8a23db78df88275d3d8a42ccf1f29e55112ac995b3a26a522c36e3b5f76b0445f1eef70d696b8c7862a2b4303d7b0e7609e - languageName: node - linkType: hard - -"semver@npm:^7.3.5": +"semver@npm:^7.3.2, semver@npm:^7.3.5": version: 7.7.4 resolution: "semver@npm:7.7.4" bin: @@ -3860,6 +4081,27 @@ __metadata: languageName: node linkType: hard +"send@npm:~0.19.0, send@npm:~0.19.1": + version: 0.19.2 + resolution: "send@npm:0.19.2" + dependencies: + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:1.2.0" + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + fresh: "npm:~0.5.2" + http-errors: "npm:~2.0.1" + mime: "npm:1.6.0" + ms: "npm:2.1.3" + on-finished: "npm:~2.4.1" + range-parser: "npm:~1.2.1" + statuses: "npm:~2.0.2" + checksum: 10c0/20c2389fe0fdf3fc499938cac598bc32272287e993c4960717381a10de8550028feadfb9076f959a3a3ebdea42e1f690e116f0d16468fa56b9fd41866d3dc267 + languageName: node + linkType: hard + "serialize-error@npm:^7.0.1": version: 7.0.1 resolution: "serialize-error@npm:7.0.1" @@ -3887,6 +4129,25 @@ __metadata: languageName: node linkType: hard +"serve-static@npm:~1.16.2": + version: 1.16.3 + resolution: "serve-static@npm:1.16.3" + dependencies: + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + parseurl: "npm:~1.3.3" + send: "npm:~0.19.1" + checksum: 10c0/36320397a073c71bedf58af48a4a100fe6d93f07459af4d6f08b9a7217c04ce2a4939e0effd842dc7bece93ffcd59eb52f58c4fff2a8e002dc29ae6b219cd42b + languageName: node + linkType: hard + +"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": + version: 1.2.0 + resolution: "setprototypeof@npm:1.2.0" + checksum: 10c0/68733173026766fa0d9ecaeb07f0483f4c2dc70ca376b3b7c40b7cda909f94b0918f6c5ad5ce27a9160bdfb475efaa9d5e705a11d8eaae18f9835d20976028bc + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -3903,38 +4164,58 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^4.0.1": - version: 4.1.0 - resolution: "signal-exit@npm:4.1.0" - checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 +"side-channel-list@npm:^1.0.0": + version: 1.0.1 + resolution: "side-channel-list@npm:1.0.1" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.4" + checksum: 10c0/d346c787fd2f9f1c2fdea14f00e8250118db0e7596d85a6cb9faa75f105d31a73a8f7a341c93d7df2a2429098c3d37a77bd3be9e88c37094b8c01807bc77c7a2 languageName: node linkType: hard -"smart-buffer@npm:^4.2.0": - version: 4.2.0 - resolution: "smart-buffer@npm:4.2.0" - checksum: 10c0/a16775323e1404dd43fabafe7460be13a471e021637bc7889468eb45ce6a6b207261f454e4e530a19500cc962c4cc5348583520843b363f4193cee5c00e1e539 +"side-channel-map@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-map@npm:1.0.1" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + checksum: 10c0/010584e6444dd8a20b85bc926d934424bd809e1a3af941cace229f7fdcb751aada0fb7164f60c2e22292b7fa3c0ff0bce237081fd4cdbc80de1dc68e95430672 languageName: node linkType: hard -"socks-proxy-agent@npm:^8.0.3": - version: 8.0.5 - resolution: "socks-proxy-agent@npm:8.0.5" +"side-channel-weakmap@npm:^1.0.2": + version: 1.0.2 + resolution: "side-channel-weakmap@npm:1.0.2" dependencies: - agent-base: "npm:^7.1.2" - debug: "npm:^4.3.4" - socks: "npm:^2.8.3" - checksum: 10c0/5d2c6cecba6821389aabf18728325730504bf9bb1d9e342e7987a5d13badd7a98838cc9a55b8ed3cb866ad37cc23e1086f09c4d72d93105ce9dfe76330e9d2a6 + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + side-channel-map: "npm:^1.0.1" + checksum: 10c0/71362709ac233e08807ccd980101c3e2d7efe849edc51455030327b059f6c4d292c237f94dc0685031dd11c07dd17a68afde235d6cf2102d949567f98ab58185 languageName: node linkType: hard -"socks@npm:^2.8.3": - version: 2.8.7 - resolution: "socks@npm:2.8.7" +"side-channel@npm:^1.1.0": + version: 1.1.0 + resolution: "side-channel@npm:1.1.0" dependencies: - ip-address: "npm:^10.0.1" - smart-buffer: "npm:^4.2.0" - checksum: 10c0/2805a43a1c4bcf9ebf6e018268d87b32b32b06fbbc1f9282573583acc155860dc361500f89c73bfbb157caa1b4ac78059eac0ef15d1811eb0ca75e0bdadbc9d2 + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.3" + side-channel-list: "npm:^1.0.0" + side-channel-map: "npm:^1.0.1" + side-channel-weakmap: "npm:^1.0.2" + checksum: 10c0/cb20dad41eb032e6c24c0982e1e5a24963a28aa6122b4f05b3f3d6bf8ae7fd5474ef382c8f54a6a3ab86e0cac4d41a23bd64ede3970e5bfb50326ba02a7996e6 + languageName: node + linkType: hard + +"signal-exit@npm:^4.0.1": + version: 4.1.0 + resolution: "signal-exit@npm:4.1.0" + checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 languageName: node linkType: hard @@ -3962,12 +4243,10 @@ __metadata: languageName: node linkType: hard -"ssri@npm:^13.0.0": - version: 13.0.1 - resolution: "ssri@npm:13.0.1" - dependencies: - minipass: "npm:^7.0.3" - checksum: 10c0/cf6408a18676c57ff2ed06b8a20dc64bb3e748e5c7e095332e6aecaa2b8422b1e94a739a8453bf65156a8a47afe23757ba4ab52d3ea3b62322dc40875763e17a +"statuses@npm:~2.0.1, statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 10c0/a9947d98ad60d01f6b26727570f3bcceb6c8fa789da64fe6889908fe2e294d57503b14bf2b5af7605c2d36647259e856635cd4c49eab41667658ec9d0080ec3f languageName: node linkType: hard @@ -3981,14 +4260,14 @@ __metadata: languageName: node linkType: hard -"streamx@npm:^2.12.5, streamx@npm:^2.15.0, streamx@npm:^2.21.0": - version: 2.23.0 - resolution: "streamx@npm:2.23.0" +"streamx@npm:^2.12.5, streamx@npm:^2.15.0, streamx@npm:^2.25.0": + version: 2.25.0 + resolution: "streamx@npm:2.25.0" dependencies: events-universal: "npm:^1.0.0" fast-fifo: "npm:^1.3.2" text-decoder: "npm:^1.1.0" - checksum: 10c0/15708ce37818d588632fe1104e8febde573e33e8c0868bf583fce0703f3faf8d2a063c278e30df2270206811b69997f64eb78792099933a1fe757e786fbcbd44 + checksum: 10c0/1ecc4b722050e9088b99cde59d035e846ac97cedc3ef14a00b196d9c0b6f47d9fd18df454a19f56f0f586ab4f23fb7229069b9e8eaf22072a21bd9c909d4e0ea languageName: node linkType: hard @@ -4066,10 +4345,10 @@ __metadata: languageName: node linkType: hard -"strnum@npm:^2.1.2": - version: 2.2.0 - resolution: "strnum@npm:2.2.0" - checksum: 10c0/9a656f5048047abff8d10d0bb57761a01916e368a71e95d4f5a962b57f64b738e20672e68ba10b7de3dc78e861c77bc0566bdeed7017abdda1caf0303c929a3f +"strnum@npm:^2.2.3": + version: 2.2.3 + resolution: "strnum@npm:2.2.3" + checksum: 10c0/1ee78101f1cd73a5b32f63cfd0be501bd246801a002f5987efef903a49e9297d1b63574e302ab3c06ee5e715c524d6cbdfef010e372ec1ea848e0179836cc208 languageName: node linkType: hard @@ -4113,15 +4392,15 @@ __metadata: linkType: hard "tar@npm:^7.5.4": - version: 7.5.11 - resolution: "tar@npm:7.5.11" + version: 7.5.13 + resolution: "tar@npm:7.5.13" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10c0/b6bb420550ef50ef23356018155e956cd83282c97b6128d8d5cfe5740c57582d806a244b2ef0bf686a74ce526babe8b8b9061527623e935e850008d86d838929 + checksum: 10c0/5c65b8084799bde7a791593a1c1a45d3d6ee98182e3700b24c247b7b8f8654df4191642abbdb07ff25043d45dcff35620827c3997b88ae6c12040f64bed5076b languageName: node linkType: hard @@ -4144,12 +4423,12 @@ __metadata: linkType: hard "tinyglobby@npm:^0.2.12": - version: 0.2.15 - resolution: "tinyglobby@npm:0.2.15" + version: 0.2.16 + resolution: "tinyglobby@npm:0.2.16" dependencies: fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.3" - checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 + picomatch: "npm:^4.0.4" + checksum: 10c0/f2e09fd93dd95c41e522113b686ff6f7c13020962f8698a864a257f3d7737599afc47722b7ab726e12f8a813f779906187911ff8ee6701ede65072671a7e934b languageName: node linkType: hard @@ -4162,6 +4441,13 @@ __metadata: languageName: node linkType: hard +"toidentifier@npm:~1.0.1": + version: 1.0.1 + resolution: "toidentifier@npm:1.0.1" + checksum: 10c0/93937279934bd66cc3270016dd8d0afec14fb7c94a05c72dc57321f8bd1fa97e5bea6d1f7c89e728d077ca31ea125b78320a616a6c6cd0e6b9cb94cb864381c1 + languageName: node + linkType: hard + "ts-node@npm:^7.0.1": version: 7.0.1 resolution: "ts-node@npm:7.0.1" @@ -4187,10 +4473,17 @@ __metadata: languageName: node linkType: hard -"type-detect@npm:^4.0.0, type-detect@npm:^4.0.8": - version: 4.0.8 - resolution: "type-detect@npm:4.0.8" - checksum: 10c0/8fb9a51d3f365a7de84ab7f73b653534b61b622aa6800aecdb0f1095a4a646d3f5eb295322127b6573db7982afcd40ab492d038cf825a42093a58b1e1353e0bd +"tsscmp@npm:^1.0.6": + version: 1.0.6 + resolution: "tsscmp@npm:1.0.6" + checksum: 10c0/2f79a9455e7e3e8071995f98cdf3487ccfc91b760bec21a9abb4d90519557eafaa37246e87c92fa8bf3fef8fd30cfd0cc3c4212bb929baa9fb62494bfa4d24b2 + languageName: node + linkType: hard + +"type-detect@npm:^4.0.0, type-detect@npm:^4.1.0": + version: 4.1.0 + resolution: "type-detect@npm:4.1.0" + checksum: 10c0/df8157ca3f5d311edc22885abc134e18ff8ffbc93d6a9848af5b682730ca6a5a44499259750197250479c5331a8a75b5537529df5ec410622041650a7f293e2a languageName: node linkType: hard @@ -4201,29 +4494,39 @@ __metadata: languageName: node linkType: hard +"type-is@npm:~1.6.18": + version: 1.6.18 + resolution: "type-is@npm:1.6.18" + dependencies: + media-typer: "npm:0.3.0" + mime-types: "npm:~2.1.24" + checksum: 10c0/a23daeb538591b7efbd61ecf06b6feb2501b683ffdc9a19c74ef5baba362b4347e42f1b4ed81f5882a8c96a3bfff7f93ce3ffaf0cbbc879b532b04c97a55db9d + languageName: node + linkType: hard + "typedoc-plugin-markdown@npm:^4.0.0": - version: 4.10.0 - resolution: "typedoc-plugin-markdown@npm:4.10.0" + version: 4.11.0 + resolution: "typedoc-plugin-markdown@npm:4.11.0" peerDependencies: typedoc: 0.28.x - checksum: 10c0/20c7bc8ef68bd90053649ce223d02d4aceefed675c09efb1740c7791fbc37c10a1e25d14647605484a198f0695312eb21119015616d91c73fe1d63df5e4fb061 + checksum: 10c0/03374acfd0b5bd5af13198c043ffc31324b097647f5ffd92959647a9a277f0ece665331ff6a650ddaefdf9ff33928e138c5483e7cddbac830eb77e69b6067803 languageName: node linkType: hard "typedoc@npm:^0.28.0": - version: 0.28.17 - resolution: "typedoc@npm:0.28.17" + version: 0.28.19 + resolution: "typedoc@npm:0.28.19" dependencies: - "@gerrit0/mini-shiki": "npm:^3.17.0" + "@gerrit0/mini-shiki": "npm:^3.23.0" lunr: "npm:^2.3.9" - markdown-it: "npm:^14.1.0" - minimatch: "npm:^9.0.5" - yaml: "npm:^2.8.1" + markdown-it: "npm:^14.1.1" + minimatch: "npm:^10.2.5" + yaml: "npm:^2.8.3" peerDependencies: - typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x bin: typedoc: bin/typedoc - checksum: 10c0/25c3f6c08748debd2549e8af2c96dcdea255297924e8e0ecc78c86aea35d69c149eb5ad0a0d333a3a69d4e41a887ce55fef0aa97236789f0e658f3ad051429e8 + checksum: 10c0/a46ea8ec4e661320dacf27f1d68595bdf9d671383f20060b590f212e6ebbf9a65d4962e698e8a43804a14add1f04a3528381c338801350dc03ddc6baf33fb898 languageName: node linkType: hard @@ -4261,21 +4564,24 @@ __metadata: languageName: node linkType: hard -"unique-filename@npm:^5.0.0": - version: 5.0.0 - resolution: "unique-filename@npm:5.0.0" - dependencies: - unique-slug: "npm:^6.0.0" - checksum: 10c0/afb897e9cf4c2fb622ea716f7c2bb462001928fc5f437972213afdf1cc32101a230c0f1e9d96fc91ee5185eca0f2feb34127145874975f347be52eb91d6ccc2c +"undici-types@npm:~6.21.0": + version: 6.21.0 + resolution: "undici-types@npm:6.21.0" + checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04 languageName: node linkType: hard -"unique-slug@npm:^6.0.0": - version: 6.0.0 - resolution: "unique-slug@npm:6.0.0" - dependencies: - imurmurhash: "npm:^0.1.4" - checksum: 10c0/da7ade4cb04eb33ad0499861f82fe95ce9c7c878b7139dc54d140ecfb6a6541c18a5c8dac16188b8b379fe62c0c1f1b710814baac910cde5f4fec06212126c6a +"undici-types@npm:~7.19.0": + version: 7.19.2 + resolution: "undici-types@npm:7.19.2" + checksum: 10c0/7159f10546f9f6c47d36776bb1bbf8671e87c1e587a6fee84ae1f111ae8de4f914efa8ca0dfcd224f4f4a9dfc3f6028f627ccb5ddaccf82d7fd54671b89fac3e + languageName: node + linkType: hard + +"undici@npm:^6.25.0": + version: 6.25.0 + resolution: "undici@npm:6.25.0" + checksum: 10c0/2597cc6689bdb02c210c557b1f85febbfda65becae6e6fc1061508e2f33734d25207f81cd8af56ada9956329eb3a7bd7431e87dcfeceba20ee87059b57dcf985 languageName: node linkType: hard @@ -4286,6 +4592,13 @@ __metadata: languageName: node linkType: hard +"unpipe@npm:~1.0.0": + version: 1.0.0 + resolution: "unpipe@npm:1.0.0" + checksum: 10c0/193400255bd48968e5c5383730344fbb4fa114cdedfab26e329e50dd2d81b134244bb8a72c6ac1b10ab0281a58b363d06405632c9d49ca9dfd5e90cbd7d0f32c + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -4293,6 +4606,13 @@ __metadata: languageName: node linkType: hard +"utils-merge@npm:1.0.1": + version: 1.0.1 + resolution: "utils-merge@npm:1.0.1" + checksum: 10c0/02ba649de1b7ca8854bfe20a82f1dfbdda3fb57a22ab4a8972a63a34553cf7aa51bc9081cf7e001b035b88186d23689d69e71b510e610a09a4c66f68aa95b672 + languageName: node + linkType: hard + "uuid@npm:^9.0.0": version: 9.0.1 resolution: "uuid@npm:9.0.1" @@ -4302,6 +4622,13 @@ __metadata: languageName: node linkType: hard +"vary@npm:~1.1.2": + version: 1.1.2 + resolution: "vary@npm:1.1.2" + checksum: 10c0/f15d588d79f3675135ba783c91a4083dcd290a2a5be9fcb6514220a1634e23df116847b1cc51f66bfb0644cf9353b2abb7815ae499bab06e46dd33c1a6bf1f4f + languageName: node + linkType: hard + "wait-queue@npm:^1.1.4": version: 1.1.4 resolution: "wait-queue@npm:1.1.4" @@ -4385,6 +4712,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.0": + version: 8.20.0 + resolution: "ws@npm:8.20.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/956ac5f11738c914089b65878b9223692ace77337ba55379ae68e1ecbeae9b47a0c6eb9403688f609999a58c80d83d99865fe0029b229d308b08c1ef93d4ea14 + languageName: node + linkType: hard + "xml@npm:^1.0.0": version: 1.0.1 resolution: "xml@npm:1.0.1" @@ -4399,13 +4741,6 @@ __metadata: languageName: node linkType: hard -"yallist@npm:^4.0.0": - version: 4.0.0 - resolution: "yallist@npm:4.0.0" - checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a - languageName: node - linkType: hard - "yallist@npm:^5.0.0": version: 5.0.0 resolution: "yallist@npm:5.0.0" @@ -4413,12 +4748,12 @@ __metadata: languageName: node linkType: hard -"yaml@npm:^2.8.1": - version: 2.8.2 - resolution: "yaml@npm:2.8.2" +"yaml@npm:^2.8.3": + version: 2.8.3 + resolution: "yaml@npm:2.8.3" bin: yaml: bin.mjs - checksum: 10c0/703e4dc1e34b324aa66876d63618dcacb9ed49f7e7fe9b70f1e703645be8d640f68ab84f12b86df8ac960bac37acf5513e115de7c970940617ce0343c8c9cd96 + checksum: 10c0/ddff0e11c1b467728d7eb4633db61c5f5de3d8e9373cf84d08fb0cdee03e1f58f02b9f1c51a4a8a865751695addbd465a77f73f1079be91fe5493b29c305fd77 languageName: node linkType: hard From d68bfdccd101d238fe34f4ef97f3d7b8bdffa90a Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Wed, 3 Jun 2026 09:56:29 -0700 Subject: [PATCH 2/7] autoconfig v2: address review feedback (leak, guards, races) - osn-streaming: drop extra obs_data_addref on originalServiceSettings; obs_service_get_settings already returns an owned ref (was a leak). - osn-video: restore the connecting-outputs guard with an APIv2 helper (iterate IStreaming manager, force-stop connecting outputs) in place of the commented-out OBS_service::stopConnectingOutputs() in Set/RemoveVideoContext. - nodeobs_autoconfig: drive bandwidth connect-wait off obs_output_active() instead of drained signals so a target that hasn't signalled yet isn't treated as connected (avoids totalBytes == 0). - nodeobs_autoconfig: wait for outputs to deactivate in applyResults before obs_set_video_info (obs_output_stop is async; avoids OBS_VIDEO_CURRENTLY_ACTIVE). - test_nodeobs_autoconfig: fix stale comment (startAutoconfig takes IStreaming[]; error string is no_streaming_targets_provided). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../source/nodeobs_autoconfig.cpp | 30 +++++++++++++++---- obs-studio-server/source/osn-streaming.cpp | 3 +- obs-studio-server/source/osn-video.cpp | 28 ++++++++++++----- .../osn-tests/src/test_nodeobs_autoconfig.ts | 7 +++-- 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/obs-studio-server/source/nodeobs_autoconfig.cpp b/obs-studio-server/source/nodeobs_autoconfig.cpp index 50d7e5071..61a45b96b 100644 --- a/obs-studio-server/source/nodeobs_autoconfig.cpp +++ b/obs-studio-server/source/nodeobs_autoconfig.cpp @@ -910,15 +910,20 @@ void autoConfig::TestBandwidthThreadV2(void) allConnected = true; for (auto *streaming : testingServices) { - std::string signal = streaming->testQuery(); - - if (signal == "error") { + // Surface a connection failure immediately; drained signals are + // only supplemental to the real output state checked below. + if (streaming->testQuery() == "error") { gotError = true; break; - } else if (signal == "start" || signal == "starting" || signal == "activate" || signal == "reconnect" || - signal == "reconnect_success") { - allConnected = false; } + + // Primary readiness check: a target counts as connected only once + // libobs reports the output active. Keying off drained signals alone + // would treat a target that hasn't emitted one yet as ready, exit + // the wait early, and measure totalBytes == 0. + obs_output_t *output = streaming->GetOutput(); + if (!output || !obs_output_active(output)) + allConnected = false; } std::unique_lock ul(m); @@ -2132,6 +2137,19 @@ static void applyResults() obs_output_stop(st.streaming->GetOutput()); } + // obs_output_stop() is asynchronous; wait for outputs to fully deactivate + // before mutating the video context, or obs_set_video_info() below can return + // OBS_VIDEO_CURRENTLY_ACTIVE (the same race Streaming::CleanTestMode guards). + { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + for (auto &st : streamingTargets) { + obs_output_t *output = st.streaming->GetOutput(); + while (output && obs_output_active(output) && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + } + } + // 1. Resolution / FPS — applied to each video context referenced by a streaming // target. Must run before encoders (encoder video-mix indices are tied to the // video context). diff --git a/obs-studio-server/source/osn-streaming.cpp b/obs-studio-server/source/osn-streaming.cpp index 0094a87d0..d7e78ae33 100644 --- a/obs-studio-server/source/osn-streaming.cpp +++ b/obs-studio-server/source/osn-streaming.cpp @@ -64,8 +64,9 @@ void osn::Streaming::testBandwidth(bool &gotError, int testBitrate) if (originalServiceSettings) { obs_data_release(originalServiceSettings); } + // obs_service_get_settings() returns an owned reference; CleanTestMode() and the + // destructor each release it once, so don't take an extra ref (was a leak). originalServiceSettings = obs_service_get_settings(service); - obs_data_addref(originalServiceSettings); obs_data_t *serviceSettings = obs_data_create(); obs_data_apply(serviceSettings, originalServiceSettings); diff --git a/obs-studio-server/source/osn-video.cpp b/obs-studio-server/source/osn-video.cpp index 15e2adc97..b36ac0c48 100644 --- a/obs-studio-server/source/osn-video.cpp +++ b/obs-studio-server/source/osn-video.cpp @@ -21,11 +21,28 @@ #include #include "osn-error.hpp" #include "shared.hpp" +#include "osn-streaming.hpp" // DELETE ME WHEN REMOVING NODEOBS #include "nodeobs_configManager.hpp" #include "nodeobs_api.h" +namespace { +// APIv2 replacement for the legacy OBS_service::stopConnectingOutputs() guard. +// obs_set_video_info / obs_remove_video_info must not run while a streaming output +// is still connecting, or libobs races on the video pointer. +void stopConnectingStreamingOutputs() +{ + osn::IStreaming::Manager::GetInstance().for_each([](osn::Streaming *streaming) { + if (!streaming) + return; + obs_output_t *output = streaming->GetOutput(); + if (output && obs_output_connecting(output)) + obs_output_force_stop(output); + }); +} +} // namespace + void osn::Video::Register(ipc::server &srv) { std::shared_ptr cls = std::make_shared("Video"); @@ -341,9 +358,8 @@ void osn::Video::SetVideoContext(void *data, const int64_t id, const std::vector int ret = OBS_VIDEO_FAIL; try { - // Cannot disrupt video ptr inside obs while outputs are connecting - // TODO APIv2 have to deprecate OBS_service and replace this call with APIv2 equivalent - //OBS_service::stopConnectingOutputs(); + // Cannot disrupt video ptr inside obs while outputs are connecting. + stopConnectingStreamingOutputs(); ret = obs_set_video_info(canvas, &video); } catch (const char *error) { blog(LOG_ERROR, "Failed to set video context %s", error); @@ -402,10 +418,8 @@ void osn::Video::RemoveVideoContext(void *data, const int64_t id, const std::vec int ret = OBS_VIDEO_FAIL; try { - // Cannot disrupt video ptr inside obs while outputs are connecting - // TODO APIv2 have to deprecate OBS_service and replace this call with APIv2 equivalent - //OBS_service::stopConnectingOutputs(); - + // Cannot disrupt video ptr inside obs while outputs are connecting. + stopConnectingStreamingOutputs(); ret = obs_remove_video_info(canvas); } catch (const char *error) { blog(LOG_ERROR, "Error occurred while removing video %s", error); diff --git a/tests/osn-tests/src/test_nodeobs_autoconfig.ts b/tests/osn-tests/src/test_nodeobs_autoconfig.ts index a1965a4de..335c5fd32 100644 --- a/tests/osn-tests/src/test_nodeobs_autoconfig.ts +++ b/tests/osn-tests/src/test_nodeobs_autoconfig.ts @@ -15,9 +15,10 @@ import { deleteConfigFiles } from '../util/general'; // - assertions via obs.getSetting('Output', 'VBitrate') etc. // // Both halves of that contract are gone after the autoconfig-v2 port: -// 1. obs.startAutoconfig() is now zero-arg — the server auto-discovers all -// registered streaming targets via the IStreaming manager. The bandwidth -// test emits 'no_streaming_target_provided' if zero targets are registered. +// 1. obs.startAutoconfig() now requires an explicit osn.IStreaming[] — the +// frontend passes the targets to test (see startAutoconfig in obs_handler.ts); +// the server no longer discovers or persists them. The bandwidth test emits +// 'no_streaming_targets_provided' if the array is empty. // 2. SaveStreamSettings / SaveSettings no longer write to basic.ini — Phase 2 // replaced them with applyResults() which mutates live osn objects via // obs_service_update / obs_encoder_update / obs_set_video_info. The From 8458a9d6dfcf35b575dd9e6f6b346498031e33e8 Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Wed, 3 Jun 2026 12:23:39 -0700 Subject: [PATCH 3/7] Fix clang-format-13 formatting in nodeobs_autoconfig Join wrapped ThrowAsJavaScriptException calls (client) and wrap the InitializeAutoConfig register_function call (server) to satisfy the clang-format-13 CI check. Co-Authored-By: Claude Opus 4.8 (1M context) --- obs-studio-client/source/nodeobs_autoconfig.cpp | 6 ++---- obs-studio-server/source/nodeobs_autoconfig.cpp | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/obs-studio-client/source/nodeobs_autoconfig.cpp b/obs-studio-client/source/nodeobs_autoconfig.cpp index 0714178ea..a41e96948 100644 --- a/obs-studio-client/source/nodeobs_autoconfig.cpp +++ b/obs-studio-client/source/nodeobs_autoconfig.cpp @@ -110,8 +110,7 @@ void autoConfig::stop_worker() Napi::Value autoConfig::InitializeAutoConfig(const Napi::CallbackInfo &info) { if (info.Length() < 2 || !info[0].IsArray() || !info[1].IsFunction()) { - Napi::TypeError::New(info.Env(), "InitializeAutoConfig expects (streamings: IStreaming[], callback)") - .ThrowAsJavaScriptException(); + Napi::TypeError::New(info.Env(), "InitializeAutoConfig expects (streamings: IStreaming[], callback)").ThrowAsJavaScriptException(); return info.Env().Undefined(); } @@ -119,8 +118,7 @@ Napi::Value autoConfig::InitializeAutoConfig(const Napi::CallbackInfo &info) std::vector uids(array.Length()); for (uint32_t i = 0; i < array.Length(); i++) { if (!osn::TryUnwrapStreamingUid(array.Get(i), uids[i])) { - Napi::TypeError::New(info.Env(), "InitializeAutoConfig: streamings[i] is not an IStreaming instance") - .ThrowAsJavaScriptException(); + Napi::TypeError::New(info.Env(), "InitializeAutoConfig: streamings[i] is not an IStreaming instance").ThrowAsJavaScriptException(); return info.Env().Undefined(); } } diff --git a/obs-studio-server/source/nodeobs_autoconfig.cpp b/obs-studio-server/source/nodeobs_autoconfig.cpp index 61a45b96b..748060e98 100644 --- a/obs-studio-server/source/nodeobs_autoconfig.cpp +++ b/obs-studio-server/source/nodeobs_autoconfig.cpp @@ -204,7 +204,8 @@ void autoConfig::Register(ipc::server &srv) { std::shared_ptr cls = std::make_shared("AutoConfig"); - cls->register_function(std::make_shared("InitializeAutoConfig", std::vector{ipc::type::Binary}, autoConfig::InitializeAutoConfig)); + cls->register_function( + std::make_shared("InitializeAutoConfig", std::vector{ipc::type::Binary}, autoConfig::InitializeAutoConfig)); cls->register_function(std::make_shared("StartBandwidthTest", std::vector{}, autoConfig::StartBandwidthTest)); cls->register_function(std::make_shared("StartStreamEncoderTest", std::vector{}, autoConfig::StartStreamEncoderTest)); cls->register_function(std::make_shared("StartRecordingEncoderTest", std::vector{}, autoConfig::StartRecordingEncoderTest)); From 68e5a49bdd5491ee7dcc65b1a33f7d2511c6e793 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Thu, 16 Jul 2026 18:03:54 +0100 Subject: [PATCH 4/7] Replace legacy flow with session optimizer API in Auto Config --- js/module.d.ts | 138 +- js/module.ts | 190 +- obs-studio-client/source/controller.cpp | 6 + .../source/nodeobs_autoconfig.cpp | 496 ++- .../source/nodeobs_autoconfig.hpp | 51 +- obs-studio-server/CMakeLists.txt | 2 - obs-studio-server/source/nodeobs_api.cpp | 2 +- .../source/nodeobs_autoconfig.cpp | 3704 +++++++---------- obs-studio-server/source/nodeobs_autoconfig.h | 58 +- .../nodeobs_autoconfig_resource_sampler.cpp | 212 - .../nodeobs_autoconfig_resource_sampler.h | 112 - package.json | 2 - .../osn-tests/src/test_nodeobs_autoconfig.ts | 189 - .../src/test_osn_auto_optimizer_v1.ts | 265 ++ tests/osn-tests/src/test_osn_autoconfig_v2.ts | 433 -- tests/osn-tests/util/error_messages.ts | 18 +- tests/osn-tests/util/mock_rtmp.ts | 91 - tests/osn-tests/util/obs_handler.ts | 46 - yarn.lock | 717 +--- 19 files changed, 2417 insertions(+), 4315 deletions(-) delete mode 100644 obs-studio-server/source/nodeobs_autoconfig_resource_sampler.cpp delete mode 100644 obs-studio-server/source/nodeobs_autoconfig_resource_sampler.h delete mode 100644 tests/osn-tests/src/test_nodeobs_autoconfig.ts create mode 100644 tests/osn-tests/src/test_osn_auto_optimizer_v1.ts delete mode 100644 tests/osn-tests/src/test_osn_autoconfig_v2.ts delete mode 100644 tests/osn-tests/util/mock_rtmp.ts diff --git a/js/module.d.ts b/js/module.d.ts index 831f98756..2e08de30e 100644 --- a/js/module.d.ts +++ b/js/module.d.ts @@ -989,28 +989,120 @@ export interface IAudioTrackFactory { importLegacySettings(): void; saveLegacySettings(): void; } -export interface IAutoConfigResourcePercentile { - p50: number; - p95: number; -} -export interface IAutoConfigResourceGpu { - available: boolean; - vramUsedMB?: IAutoConfigResourcePercentile; - vramBudgetMB?: number; -} -export type AutoConfigResourcePhase = 'bandwidth' | 'stream_encoder' | 'recording_encoder'; -export interface IAutoConfigResourceUsage { - phase: AutoConfigResourcePhase; - sampleCount: number; - durationMs: number; - cpuPct: IAutoConfigResourcePercentile; - procRamMB: IAutoConfigResourcePercentile; - gpu: IAutoConfigResourceGpu; -} -export interface IAutoConfigSummary { - complete: boolean; - resourceUsage: IAutoConfigResourceUsage[]; - [key: string]: unknown; +export interface IAutoConfigCapabilities { + apiVersion: 2; + resultSchemaVersion: 1; + previewApplySplit: true; + awaitableCancel: true; + perUploadLegResults: true; + desktopOwnedApply: true; + bandwidthModes: ['twitch-standard-active', 'estimate']; +} +export type AutoConfigTopology = 'direct-single' | 'cloud-multistream' | 'custom-rtmp' | 'dual-output' | 'enhanced-broadcasting' | 'stream-shift' | 'mixed'; +export type AutoConfigDisplay = 'horizontal' | 'vertical' | 'both'; +export type AutoConfigPlatform = 'twitch' | 'youtube' | 'facebook' | 'kick' | 'tiktok' | 'custom' | 'other'; +export type AutoConfigEstimateReason = 'non_twitch' | 'custom_rtmp' | 'cloud_multistream' | 'dual_output' | 'enhanced_broadcasting' | 'stream_shift' | 'mixed_topology' | 'probe_disabled'; +export interface IAutoConfigDestination { + platform: AutoConfigPlatform; +} +export interface IAutoConfigCurrentSettings { + width: number; + height: number; + fpsNum: number; + fpsDen: number; + bitrateKbps: number; + encoderId: string; + codec: string; + preset?: string; +} +export interface IAutoConfigLimits { + maxBitrateKbps?: number; + maxWidth?: number; + maxHeight?: number; + maxFpsNum?: number; + maxFpsDen?: number; +} +export interface IAutoConfigLegRequest { + legId: string; + display: AutoConfigDisplay; + destinations: IAutoConfigDestination[]; + current: IAutoConfigCurrentSettings; + limits?: IAutoConfigLimits; + estimateReason?: AutoConfigEstimateReason; +} +export interface IAutoConfigActiveProbe { + kind: 'twitch-standard-v1'; + legId: string; + serviceName: 'Twitch'; + server: string; + streamKey: string; +} +export interface IAutoConfigRequest { + schemaVersion: 1; + topology: AutoConfigTopology; + legs: IAutoConfigLegRequest[]; + activeProbe?: IAutoConfigActiveProbe; +} +export type AutoConfigEventType = 'phase' | 'progress' | 'result' | 'error' | 'cancelled' | 'complete'; +export type AutoConfigPhase = 'preflight' | 'hardware' | 'bandwidth' | 'recommendation' | 'cleanup'; +export type AutoConfigMeasurementMode = 'active' | 'estimated'; +export interface IAutoConfigEvent { + schemaVersion: 1; + sessionId: string; + sequence: number; + type: AutoConfigEventType; + phase: AutoConfigPhase; + progress: number; + code?: string; + legId?: string; + measurementMode?: AutoConfigMeasurementMode; +} +export interface IAutoConfigMeasurement { + mode: AutoConfigMeasurementMode; + confidence: 'high' | 'medium' | 'low'; + reason?: string; +} +export interface IAutoConfigRecommendation { + width: number; + height: number; + fpsNum: number; + fpsDen: number; + bitrateKbps: number; + encoderId: string; + codec: string; + preset?: string; +} +export interface IAutoConfigResultDestination { + platform: string; +} +export interface IAutoConfigLegResult { + legId: string; + display: AutoConfigDisplay; + destinations: IAutoConfigResultDestination[]; + measurement: IAutoConfigMeasurement; + recommendation: IAutoConfigRecommendation; + limits?: IAutoConfigLimits; +} +export interface IAutoConfigError { + code: string; +} +export interface IAutoConfigResult { + schemaVersion: 1; + sessionId: string; + status: 'complete' | 'partial' | 'cancelled' | 'failed'; + error?: IAutoConfigError; + legs: IAutoConfigLegResult[]; +} +export interface IAutoConfigNativeApi { + GetAutoConfigCapabilities(): string; + CreateAutoConfigSession(requestJson: string, callback: (event: IAutoConfigEvent) => void): string; + StartAutoConfigSession(sessionId: string): void; + GetAutoConfigResult(sessionId: string): string; + CancelAutoConfigSession(sessionId: string): void; + CloseAutoConfigSession(sessionId: string): void; +} +export interface INodeObsApi extends IAutoConfigNativeApi { + [key: string]: any; } export declare const enum VCamOutputType { Invalid = 0, @@ -1019,4 +1111,4 @@ export declare const enum VCamOutputType { ProgramView = 3, PreviewOutput = 4 } -export declare const NodeObs: any; +export declare const NodeObs: INodeObsApi; diff --git a/js/module.ts b/js/module.ts index a717a40f5..883512bde 100644 --- a/js/module.ts +++ b/js/module.ts @@ -1961,46 +1961,170 @@ export interface IAudioTrackFactory { saveLegacySettings(): void; } -// ---- Autoconfig resource-usage telemetry ---- -// -// Shapes for the JSON payload of the autoconfig 'resource_usage' event, and -// the matching `resourceUsage` array inside GetAutoConfigSummary()'s JSON. -// -// p50 is the typical value during the phase; p95 is the sustained ceiling -// after dropping single-sample spikes from unrelated OS noise. min / max / avg -// are deliberately not exposed — max overweights one-off background activity -// and avg is hard to act on. +// ---- Auto Optimizer API v1 (native API version 2) ---- + +export interface IAutoConfigCapabilities { + apiVersion: 2; + resultSchemaVersion: 1; + previewApplySplit: true; + awaitableCancel: true; + perUploadLegResults: true; + desktopOwnedApply: true; + bandwidthModes: ['twitch-standard-active', 'estimate']; +} + +export type AutoConfigTopology = + 'direct-single' | + 'cloud-multistream' | + 'custom-rtmp' | + 'dual-output' | + 'enhanced-broadcasting' | + 'stream-shift' | + 'mixed'; + +export type AutoConfigDisplay = 'horizontal' | 'vertical' | 'both'; + +export type AutoConfigPlatform = + 'twitch' | + 'youtube' | + 'facebook' | + 'kick' | + 'tiktok' | + 'custom' | + 'other'; + +export type AutoConfigEstimateReason = + 'non_twitch' | + 'custom_rtmp' | + 'cloud_multistream' | + 'dual_output' | + 'enhanced_broadcasting' | + 'stream_shift' | + 'mixed_topology' | + 'probe_disabled'; + +export interface IAutoConfigDestination { + platform: AutoConfigPlatform; +} + +export interface IAutoConfigCurrentSettings { + width: number; + height: number; + fpsNum: number; + fpsDen: number; + bitrateKbps: number; + encoderId: string; + codec: string; + preset?: string; +} + +export interface IAutoConfigLimits { + maxBitrateKbps?: number; + maxWidth?: number; + maxHeight?: number; + maxFpsNum?: number; + maxFpsDen?: number; +} + +export interface IAutoConfigLegRequest { + legId: string; + display: AutoConfigDisplay; + destinations: IAutoConfigDestination[]; + current: IAutoConfigCurrentSettings; + limits?: IAutoConfigLimits; + estimateReason?: AutoConfigEstimateReason; +} -export interface IAutoConfigResourcePercentile { - p50: number; - p95: number; +export interface IAutoConfigActiveProbe { + kind: 'twitch-standard-v1'; + legId: string; + serviceName: 'Twitch'; + server: string; + streamKey: string; } -export interface IAutoConfigResourceGpu { - available: boolean; - vramUsedMB?: IAutoConfigResourcePercentile; - vramBudgetMB?: number; +export interface IAutoConfigRequest { + schemaVersion: 1; + topology: AutoConfigTopology; + legs: IAutoConfigLegRequest[]; + activeProbe?: IAutoConfigActiveProbe; } -export type AutoConfigResourcePhase = 'bandwidth' | 'stream_encoder' | 'recording_encoder'; +export type AutoConfigEventType = 'phase' | 'progress' | 'result' | 'error' | 'cancelled' | 'complete'; +export type AutoConfigPhase = 'preflight' | 'hardware' | 'bandwidth' | 'recommendation' | 'cleanup'; +export type AutoConfigMeasurementMode = 'active' | 'estimated'; -export interface IAutoConfigResourceUsage { - phase: AutoConfigResourcePhase; - sampleCount: number; - durationMs: number; - cpuPct: IAutoConfigResourcePercentile; - procRamMB: IAutoConfigResourcePercentile; - gpu: IAutoConfigResourceGpu; +export interface IAutoConfigEvent { + schemaVersion: 1; + sessionId: string; + sequence: number; + type: AutoConfigEventType; + phase: AutoConfigPhase; + progress: number; + code?: string; + legId?: string; + measurementMode?: AutoConfigMeasurementMode; } -// Parsed shape of NodeObs.GetAutoConfigSummary(). Only the fields the -// resource-usage feature consumes are typed; other historical fields -// (encoderDetection, videoDecision, bandwidthTest, selection) are present in -// the JSON but intentionally left as `unknown` — type them when you need them. -export interface IAutoConfigSummary { - complete: boolean; - resourceUsage: IAutoConfigResourceUsage[]; - [key: string]: unknown; +export interface IAutoConfigMeasurement { + mode: AutoConfigMeasurementMode; + confidence: 'high' | 'medium' | 'low'; + reason?: string; +} + +export interface IAutoConfigRecommendation { + width: number; + height: number; + fpsNum: number; + fpsDen: number; + bitrateKbps: number; + encoderId: string; + codec: string; + preset?: string; +} + +export interface IAutoConfigResultDestination { + platform: string; +} + +export interface IAutoConfigLegResult { + legId: string; + display: AutoConfigDisplay; + destinations: IAutoConfigResultDestination[]; + measurement: IAutoConfigMeasurement; + recommendation: IAutoConfigRecommendation; + limits?: IAutoConfigLimits; +} + +export interface IAutoConfigError { + code: string; +} + +export interface IAutoConfigResult { + schemaVersion: 1; + sessionId: string; + status: 'complete' | 'partial' | 'cancelled' | 'failed'; + error?: IAutoConfigError; + legs: IAutoConfigLegResult[]; +} + +/** Raw native methods. Requests and results cross IPC as JSON strings. */ +export interface IAutoConfigNativeApi { + GetAutoConfigCapabilities(): string; + CreateAutoConfigSession(requestJson: string, callback: (event: IAutoConfigEvent) => void): string; + StartAutoConfigSession(sessionId: string): void; + GetAutoConfigResult(sessionId: string): string; + CancelAutoConfigSession(sessionId: string): void; + CloseAutoConfigSession(sessionId: string): void; +} + +/** + * Most of the addon's non-optimizer surface remains dynamically typed. This + * intersection gives the Auto Optimizer methods useful declarations without + * changing unrelated callers. + */ +export interface INodeObsApi extends IAutoConfigNativeApi { + [key: string]: any; } export const enum VCamOutputType { @@ -2024,4 +2148,4 @@ else if (fs.existsSync(path.resolve(__dirname, `obs64.exe`).replace('app.asar', else { obs.IPC.setServerPath(path.resolve(__dirname, `obs32.exe`).replace('app.asar', 'app.asar.unpacked'), path.resolve(__dirname).replace('app.asar', 'app.asar.unpacked')); } -export const NodeObs = obs; +export const NodeObs: INodeObsApi = obs; diff --git a/obs-studio-client/source/controller.cpp b/obs-studio-client/source/controller.cpp index 651554b72..fa26b6dc3 100644 --- a/obs-studio-client/source/controller.cpp +++ b/obs-studio-client/source/controller.cpp @@ -17,6 +17,7 @@ ******************************************************************************/ #include "controller.hpp" +#include "nodeobs_autoconfig.hpp" #include #include #include @@ -380,6 +381,11 @@ std::shared_ptr Controller::connect(const std::string &uri) void Controller::disconnect() { + // AutoConfig owns a polling thread and a Node ThreadSafeFunction. Tear them + // down before dropping IPC so an abandoned session cannot keep the renderer + // environment or addon alive. + autoConfig::Shutdown(); + if (m_isServer) { m_connection->call_synchronous_helper("System", "Shutdown", {}); m_isServer = false; diff --git a/obs-studio-client/source/nodeobs_autoconfig.cpp b/obs-studio-client/source/nodeobs_autoconfig.cpp index a41e96948..287031fe0 100644 --- a/obs-studio-client/source/nodeobs_autoconfig.cpp +++ b/obs-studio-client/source/nodeobs_autoconfig.cpp @@ -19,322 +19,394 @@ #include "nodeobs_autoconfig.hpp" #include "polling-pacer.hpp" #include "shared.hpp" -#include "streaming.hpp" -#include - -bool autoConfig::isWorkerRunning = false; -bool autoConfig::worker_stop = true; -std::chrono::milliseconds autoConfig::sleepInterval(33); -Napi::ThreadSafeFunction autoConfig::js_thread; -std::thread *autoConfig::worker_thread = nullptr; -std::vector autoConfig::ac_queue_task_workers; - -#ifdef WIN32 -const char *ac_sem_name = nullptr; // Not used on Windows -HANDLE ac_sem; -#else -const char *ac_sem_name = "autoconfig-semaphore"; -sem_t *ac_sem; -#endif - -void autoConfig::worker() +#include "utility-v8.hpp" + +#include +#include +#include +#include +#include + +namespace { +struct AutoConfigEvent { + uint32_t schemaVersion = 0; + std::string sessionId; + uint64_t sequence = 0; + std::string type; + std::string phase; + double progress = 0; + std::string code; + std::string legId; + std::string measurementMode; +}; + +std::atomic workerStop{true}; +constexpr std::chrono::milliseconds sleepInterval(33); +Napi::ThreadSafeFunction jsThread; +bool jsThreadActive = false; +std::thread *workerThread = nullptr; +std::mutex sessionMutex; +std::mutex lifecycleMutex; +std::string activeSessionId; + +enum class CallbackShutdownMode { Release, Abort }; + +std::string GetActiveSessionId() { - osn::PollingPacer pacer(sleepInterval); - while (!worker_stop) { - auto tp_start = std::chrono::high_resolution_clock::now(); - - auto conn = Controller::GetInstance().GetConnection(); - if (!conn) { - goto do_sleep; - } - - { - std::vector response = conn->call_synchronous_helper("AutoConfig", "Query", {}); - if (!response.size() || (response.size() == 1)) { - goto do_sleep; - } - - ErrorCode error = (ErrorCode)response[0].value_union.ui64; - if (error == ErrorCode::Ok) { - AutoConfigInfo *data = new AutoConfigInfo; - - data->event = response[1].value_str; - data->description = response[2].value_str; - data->percentage = response[3].value_union.fp64; - // Optional 5th payload field (added for the POC UI). Older - // servers won't include it — guard the read. - if (response.size() >= 5) - data->payload = response[4].value_str; - ac_queue_task_workers.push_back(new std::thread(&autoConfig::queueTask, data)); - } - } - - do_sleep: - auto tp_end = std::chrono::high_resolution_clock::now(); - auto dur = std::chrono::duration_cast(tp_end - tp_start); - const bool shouldSleep = pacer.finishCycle(dur); - if (shouldSleep) - std::this_thread::sleep_for(pacer.sleepDuration()); - } - return; + std::lock_guard lock(sessionMutex); + return activeSessionId; } -void autoConfig::start_worker() +void SetActiveSessionId(const std::string &sessionId) { - if (!worker_stop) - return; - - worker_stop = false; - ac_sem = create_semaphore(ac_sem_name); - worker_thread = new std::thread(&autoConfig::worker); + std::lock_guard lock(sessionMutex); + activeSessionId = sessionId; } -void autoConfig::stop_worker() +uint64_t ReadUnsigned(const ipc::value &value) { - if (worker_stop != false) - return; - - worker_stop = true; - if (worker_thread->joinable()) { - worker_thread->join(); - } - for (auto queue_worker : ac_queue_task_workers) { - if (queue_worker->joinable()) { - queue_worker->join(); - } + switch (value.type) { + case ipc::type::UInt32: + return value.value_union.ui32; + case ipc::type::UInt64: + return value.value_union.ui64; + case ipc::type::Int32: + return value.value_union.i32 < 0 ? 0 : static_cast(value.value_union.i32); + case ipc::type::Int64: + return value.value_union.i64 < 0 ? 0 : static_cast(value.value_union.i64); + default: + return 0; } - remove_semaphore(ac_sem, ac_sem_name); - js_thread.Release(); } -Napi::Value autoConfig::InitializeAutoConfig(const Napi::CallbackInfo &info) +double ReadDouble(const ipc::value &value) { - if (info.Length() < 2 || !info[0].IsArray() || !info[1].IsFunction()) { - Napi::TypeError::New(info.Env(), "InitializeAutoConfig expects (streamings: IStreaming[], callback)").ThrowAsJavaScriptException(); - return info.Env().Undefined(); + switch (value.type) { + case ipc::type::Float: + return value.value_union.fp32; + case ipc::type::Double: + return value.value_union.fp64; + case ipc::type::Int32: + return value.value_union.i32; + case ipc::type::Int64: + return static_cast(value.value_union.i64); + case ipc::type::UInt32: + return value.value_union.ui32; + case ipc::type::UInt64: + return static_cast(value.value_union.ui64); + default: + return 0; } +} - Napi::Array array = info[0].As(); - std::vector uids(array.Length()); - for (uint32_t i = 0; i < array.Length(); i++) { - if (!osn::TryUnwrapStreamingUid(array.Get(i), uids[i])) { - Napi::TypeError::New(info.Env(), "InitializeAutoConfig: streamings[i] is not an IStreaming instance").ThrowAsJavaScriptException(); - return info.Env().Undefined(); - } +bool GetSessionArgument(const Napi::CallbackInfo &info, const char *method, std::string &sessionId) +{ + if (info.Length() < 1 || !info[0].IsString()) { + Napi::TypeError::New(info.Env(), std::string(method) + " expects (sessionId: string)").ThrowAsJavaScriptException(); + return false; } - std::vector uidsBin(uids.size() * sizeof(uint64_t)); - if (!uids.empty()) - memcpy(uidsBin.data(), uids.data(), uidsBin.size()); - Napi::Function async_callback = info[1].As(); + sessionId = info[0].As().Utf8Value(); + if (sessionId.empty()) { + Napi::TypeError::New(info.Env(), std::string(method) + " expects a non-empty sessionId").ThrowAsJavaScriptException(); + return false; + } - auto conn = GetConnection(info); - if (!conn) - return info.Env().Undefined(); + return true; +} - std::vector response = conn->call_synchronous_helper("AutoConfig", "InitializeAutoConfig", {ipc::value(uidsBin)}); +void DispatchEvent(AutoConfigEvent *event) +{ + auto callback = [](Napi::Env env, Napi::Function jsCallback, AutoConfigEvent *eventData) { + try { + Napi::Object result = Napi::Object::New(env); + result.Set("schemaVersion", Napi::Number::New(env, eventData->schemaVersion)); + result.Set("sessionId", Napi::String::New(env, eventData->sessionId)); + result.Set("sequence", Napi::Number::New(env, static_cast(eventData->sequence))); + result.Set("type", Napi::String::New(env, eventData->type)); + result.Set("phase", Napi::String::New(env, eventData->phase)); + result.Set("progress", Napi::Number::New(env, eventData->progress)); + + if (!eventData->code.empty()) + result.Set("code", Napi::String::New(env, eventData->code)); + if (!eventData->legId.empty()) + result.Set("legId", Napi::String::New(env, eventData->legId)); + if (!eventData->measurementMode.empty()) + result.Set("measurementMode", Napi::String::New(env, eventData->measurementMode)); - if (!ValidateResponse(info, response)) - return info.Env().Undefined(); + jsCallback.Call({result}); + } catch (...) { + } + delete eventData; + }; - if (isWorkerRunning) - stop_worker(); + if (jsThread.NonBlockingCall(event, callback) != napi_ok) + delete event; +} - js_thread = Napi::ThreadSafeFunction::New(info.Env(), async_callback, "AutoConfig", 0, 1, [](Napi::Env) {}); +void Worker() +{ + osn::PollingPacer pacer(sleepInterval); - start_worker(); - isWorkerRunning = true; + while (!workerStop.load()) { + const auto cycleStart = std::chrono::high_resolution_clock::now(); + try { + auto conn = Controller::GetInstance().GetConnection(); + const std::string sessionId = GetActiveSessionId(); + + if (conn && !sessionId.empty()) { + std::vector response = + conn->call_synchronous_helper("AutoConfig", "QueryAutoConfigSession", {ipc::value(sessionId)}); + if (response.size() >= 10 && static_cast(response[0].value_union.ui64) == ErrorCode::Ok) { + auto *event = new AutoConfigEvent; + event->schemaVersion = static_cast(ReadUnsigned(response[1])); + event->sessionId = response[2].value_str; + event->sequence = ReadUnsigned(response[3]); + event->type = response[4].value_str; + event->phase = response[5].value_str; + event->progress = ReadDouble(response[6]); + event->code = response[7].value_str; + event->legId = response[8].value_str; + event->measurementMode = response[9].value_str; + + if (event->sessionId == sessionId) + DispatchEvent(event); + else + delete event; + } + } + } catch (...) { + // A peer disappearing must never escape the native polling thread. + // Explicit IPC disconnect and environment teardown set workerStop and + // perform the corresponding join/ThreadSafeFunction abort. + } - return Napi::Boolean::New(info.Env(), true); + const auto cycleEnd = std::chrono::high_resolution_clock::now(); + const auto duration = std::chrono::duration_cast(cycleEnd - cycleStart); + if (pacer.finishCycle(duration)) + std::this_thread::sleep_for(pacer.sleepDuration()); + } } -Napi::Value autoConfig::StartBandwidthTest(const Napi::CallbackInfo &info) +bool IsWorkerRunning() { - auto conn = GetConnection(info); - if (!conn) - return info.Env().Undefined(); + return !workerStop.load(); +} - std::vector response = conn->call_synchronous_helper("AutoConfig", "StartBandwidthTest", {}); - if (!ValidateResponse(info, response)) - return info.Env().Undefined(); +void StartWorker() +{ + if (IsWorkerRunning()) + return; - return info.Env().Undefined(); + workerStop.store(false); + workerThread = new std::thread(Worker); } -Napi::Value autoConfig::StartStreamEncoderTest(const Napi::CallbackInfo &info) +void StopWorker(CallbackShutdownMode mode) { - auto conn = GetConnection(info); - if (!conn) - return info.Env().Undefined(); + workerStop.store(true); + if (workerThread && workerThread->joinable()) + workerThread->join(); + delete workerThread; + workerThread = nullptr; + if (jsThreadActive) { + if (mode == CallbackShutdownMode::Abort) + jsThread.Abort(); + else + jsThread.Release(); + jsThreadActive = false; + jsThread = Napi::ThreadSafeFunction(); + } +} - std::vector response = conn->call_synchronous_helper("AutoConfig", "StartStreamEncoderTest", {}); - if (!ValidateResponse(info, response)) - return info.Env().Undefined(); +void StopLocalSession(const std::string &sessionId, CallbackShutdownMode mode) +{ + std::lock_guard lock(lifecycleMutex); + if (GetActiveSessionId() != sessionId) + return; + StopWorker(mode); + SetActiveSessionId(""); +} - return info.Env().Undefined(); +void BestEffortServerCall(const std::shared_ptr &conn, const char *method, const std::string &sessionId) +{ + if (!conn || sessionId.empty()) + return; + try { + conn->call_synchronous_helper("AutoConfig", method, {ipc::value(sessionId)}); + } catch (...) { + // Disconnect and environment teardown must always continue locally. + } } -Napi::Value autoConfig::StartRecordingEncoderTest(const Napi::CallbackInfo &info) +Napi::Value GetAutoConfigCapabilities(const Napi::CallbackInfo &info) { auto conn = GetConnection(info); if (!conn) return info.Env().Undefined(); - std::vector response = conn->call_synchronous_helper("AutoConfig", "StartRecordingEncoderTest", {}); - if (!ValidateResponse(info, response)) + std::vector response = conn->call_synchronous_helper("AutoConfig", "GetAutoConfigCapabilities", {}); + if (!ValidateResponse(info, response) || response.size() < 2) return info.Env().Undefined(); - return info.Env().Undefined(); + return Napi::String::New(info.Env(), response[1].value_str); } -void autoConfig::queueTask(AutoConfigInfo *data) +Napi::Value CreateAutoConfigSession(const Napi::CallbackInfo &info) { - wait_semaphore(ac_sem); - - auto sources_callback = [](Napi::Env env, Napi::Function jsCallback, AutoConfigInfo *event_data) { - try { - Napi::Object result = Napi::Object::New(env); - - result.Set(Napi::String::New(env, "event"), Napi::String::New(env, event_data->event)); - result.Set(Napi::String::New(env, "description"), Napi::String::New(env, event_data->description)); - - if (event_data->event.compare("error") != 0) { - result.Set(Napi::String::New(env, "percentage"), Napi::Number::New(env, event_data->percentage)); - } - if (!event_data->payload.empty()) { - result.Set(Napi::String::New(env, "payload"), Napi::String::New(env, event_data->payload)); - } - result.Set(Napi::String::New(env, "continent"), Napi::String::New(env, "")); - - jsCallback.Call({result}); - } catch (...) { - } - delete event_data; - }; + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction()) { + Napi::TypeError::New(info.Env(), "CreateAutoConfigSession expects (requestJson: string, callback)").ThrowAsJavaScriptException(); + return info.Env().Undefined(); + } - napi_status status = js_thread.NonBlockingCall(data, sources_callback); - if (status != napi_ok) { - delete data; + if (IsWorkerRunning()) { + Napi::Error::New(info.Env(), "An AutoConfig session is already active").ThrowAsJavaScriptException(); + return info.Env().Undefined(); } - release_semaphore(ac_sem); -} -Napi::Value autoConfig::StartCheckSettings(const Napi::CallbackInfo &info) -{ - AutoConfigInfo *startData = new AutoConfigInfo; - startData->event = "starting_step"; - startData->description = "checking_settings"; - startData->percentage = 0; - ac_queue_task_workers.push_back(new std::thread(&autoConfig::queueTask, startData)); + const std::string requestJson = info[0].As().Utf8Value(); + if (requestJson.empty()) { + Napi::TypeError::New(info.Env(), "CreateAutoConfigSession expects non-empty request JSON").ThrowAsJavaScriptException(); + return info.Env().Undefined(); + } auto conn = GetConnection(info); if (!conn) return info.Env().Undefined(); - std::vector response = conn->call_synchronous_helper("AutoConfig", "StartCheckSettings", {}); - - if (!ValidateResponse(info, response)) + std::vector response = conn->call_synchronous_helper("AutoConfig", "CreateAutoConfigSession", {ipc::value(requestJson)}); + if (!ValidateResponse(info, response) || response.size() < 2) return info.Env().Undefined(); - bool success = (bool)response[1].value_union.ui32; - AutoConfigInfo *stopData = new AutoConfigInfo; - if (!success) { - stopData->event = "error"; - stopData->description = "invalid_settings"; - } else { - stopData->event = "stopping_step"; - stopData->description = "checking_settings"; + const std::string sessionId = response[1].value_str; + if (sessionId.empty()) { + Napi::Error::New(info.Env(), "CreateAutoConfigSession returned an empty sessionId").ThrowAsJavaScriptException(); + return info.Env().Undefined(); } - stopData->percentage = 100; - ac_queue_task_workers.push_back(new std::thread(&autoConfig::queueTask, stopData)); + jsThread = Napi::ThreadSafeFunction::New(info.Env(), info[1].As(), "AutoConfigSession", 0, 1, [](Napi::Env) {}); + // The poller must not keep a renderer's Node environment alive. Its cleanup + // hook below owns the final abort/join if JavaScript never closes the session. + jsThread.Unref(info.Env()); + jsThreadActive = true; + SetActiveSessionId(sessionId); + StartWorker(); - return info.Env().Undefined(); + return Napi::String::New(info.Env(), sessionId); } -Napi::Value autoConfig::StartSetDefaultSettings(const Napi::CallbackInfo &info) +Napi::Value StartAutoConfigSession(const Napi::CallbackInfo &info) { + std::string sessionId; + if (!GetSessionArgument(info, "StartAutoConfigSession", sessionId)) + return info.Env().Undefined(); + auto conn = GetConnection(info); if (!conn) return info.Env().Undefined(); - std::vector response = conn->call_synchronous_helper("AutoConfig", "StartSetDefaultSettings", {}); + std::vector response = conn->call_synchronous_helper("AutoConfig", "StartAutoConfigSession", {ipc::value(sessionId)}); if (!ValidateResponse(info, response)) return info.Env().Undefined(); return info.Env().Undefined(); } -Napi::Value autoConfig::StartSaveStreamSettings(const Napi::CallbackInfo &info) +Napi::Value GetAutoConfigResult(const Napi::CallbackInfo &info) { + std::string sessionId; + if (!GetSessionArgument(info, "GetAutoConfigResult", sessionId)) + return info.Env().Undefined(); + auto conn = GetConnection(info); if (!conn) return info.Env().Undefined(); - std::vector response = conn->call_synchronous_helper("AutoConfig", "StartSaveStreamSettings", {}); - if (!ValidateResponse(info, response)) + std::vector response = conn->call_synchronous_helper("AutoConfig", "GetAutoConfigResult", {ipc::value(sessionId)}); + if (!ValidateResponse(info, response) || response.size() < 2) return info.Env().Undefined(); - return info.Env().Undefined(); + return Napi::String::New(info.Env(), response[1].value_str); } -Napi::Value autoConfig::StartSaveSettings(const Napi::CallbackInfo &info) +Napi::Value CancelAutoConfigSession(const Napi::CallbackInfo &info) { + std::string sessionId; + if (!GetSessionArgument(info, "CancelAutoConfigSession", sessionId)) + return info.Env().Undefined(); + auto conn = GetConnection(info); if (!conn) return info.Env().Undefined(); - std::vector response = conn->call_synchronous_helper("AutoConfig", "StartSaveSettings", {}); + std::vector response = conn->call_synchronous_helper("AutoConfig", "CancelAutoConfigSession", {ipc::value(sessionId)}); if (!ValidateResponse(info, response)) return info.Env().Undefined(); + // Cancellation is awaitable: polling continues until cleanup emits its + // terminal event, after which the caller closes the session. return info.Env().Undefined(); } -Napi::Value autoConfig::GetAutoConfigSummary(const Napi::CallbackInfo &info) +Napi::Value CloseAutoConfigSession(const Napi::CallbackInfo &info) { - auto conn = GetConnection(info); - if (!conn) + std::string sessionId; + if (!GetSessionArgument(info, "CloseAutoConfigSession", sessionId)) return info.Env().Undefined(); - std::vector response = conn->call_synchronous_helper("AutoConfig", "GetAutoConfigSummary", {}); - - if (!ValidateResponse(info, response)) + auto conn = GetConnection(info); + if (!conn) { + StopLocalSession(sessionId, CallbackShutdownMode::Release); return info.Env().Undefined(); + } - if (response.size() < 2) + try { + std::vector response = conn->call_synchronous_helper("AutoConfig", "CloseAutoConfigSession", {ipc::value(sessionId)}); + const bool responseIsValid = ValidateResponse(info, response); + StopLocalSession(sessionId, CallbackShutdownMode::Release); + if (!responseIsValid) + return info.Env().Undefined(); + } catch (const std::exception &error) { + StopLocalSession(sessionId, CallbackShutdownMode::Release); + Napi::Error::New(info.Env(), error.what()).ThrowAsJavaScriptException(); return info.Env().Undefined(); + } catch (...) { + StopLocalSession(sessionId, CallbackShutdownMode::Release); + Napi::Error::New(info.Env(), "CloseAutoConfigSession IPC call failed").ThrowAsJavaScriptException(); + return info.Env().Undefined(); + } - return Napi::String::New(info.Env(), response[1].value_str); + return info.Env().Undefined(); +} } -Napi::Value autoConfig::TerminateAutoConfig(const Napi::CallbackInfo &info) +void autoConfig::Shutdown() { - auto conn = GetConnection(info); - if (!conn) - return info.Env().Undefined(); - - std::vector response = conn->call_synchronous_helper("AutoConfig", "TerminateAutoConfig", {}); - - if (!ValidateResponse(info, response)) - return info.Env().Undefined(); + std::lock_guard lock(lifecycleMutex); + const std::string sessionId = GetActiveSessionId(); + if (sessionId.empty()) { + StopWorker(CallbackShutdownMode::Abort); + return; + } - if (isWorkerRunning) - stop_worker(); + // Prevent new poll cycles while the server performs awaitable cancellation. + // An already-running query may finish, so local joining remains mandatory. + workerStop.store(true); + auto conn = Controller::GetInstance().GetConnection(); + BestEffortServerCall(conn, "CancelAutoConfigSession", sessionId); + BestEffortServerCall(conn, "CloseAutoConfigSession", sessionId); - return info.Env().Undefined(); + StopWorker(CallbackShutdownMode::Abort); + SetActiveSessionId(""); } void autoConfig::Init(Napi::Env env, Napi::Object exports) { - exports.Set(Napi::String::New(env, "InitializeAutoConfig"), Napi::Function::New(env, autoConfig::InitializeAutoConfig)); - exports.Set(Napi::String::New(env, "StartBandwidthTest"), Napi::Function::New(env, autoConfig::StartBandwidthTest)); - exports.Set(Napi::String::New(env, "StartStreamEncoderTest"), Napi::Function::New(env, autoConfig::StartStreamEncoderTest)); - exports.Set(Napi::String::New(env, "StartRecordingEncoderTest"), Napi::Function::New(env, autoConfig::StartRecordingEncoderTest)); - exports.Set(Napi::String::New(env, "StartCheckSettings"), Napi::Function::New(env, autoConfig::StartCheckSettings)); - exports.Set(Napi::String::New(env, "StartSetDefaultSettings"), Napi::Function::New(env, autoConfig::StartSetDefaultSettings)); - exports.Set(Napi::String::New(env, "StartSaveStreamSettings"), Napi::Function::New(env, autoConfig::StartSaveStreamSettings)); - exports.Set(Napi::String::New(env, "StartSaveSettings"), Napi::Function::New(env, autoConfig::StartSaveSettings)); - exports.Set(Napi::String::New(env, "TerminateAutoConfig"), Napi::Function::New(env, autoConfig::TerminateAutoConfig)); - exports.Set(Napi::String::New(env, "GetAutoConfigSummary"), Napi::Function::New(env, autoConfig::GetAutoConfigSummary)); + env.AddCleanupHook([]() { autoConfig::Shutdown(); }); + exports.Set("GetAutoConfigCapabilities", Napi::Function::New(env, GetAutoConfigCapabilities)); + exports.Set("CreateAutoConfigSession", Napi::Function::New(env, CreateAutoConfigSession)); + exports.Set("StartAutoConfigSession", Napi::Function::New(env, StartAutoConfigSession)); + exports.Set("GetAutoConfigResult", Napi::Function::New(env, GetAutoConfigResult)); + exports.Set("CancelAutoConfigSession", Napi::Function::New(env, CancelAutoConfigSession)); + exports.Set("CloseAutoConfigSession", Napi::Function::New(env, CloseAutoConfigSession)); } diff --git a/obs-studio-client/source/nodeobs_autoconfig.hpp b/obs-studio-client/source/nodeobs_autoconfig.hpp index ef3cd7019..8921bbb9f 100644 --- a/obs-studio-client/source/nodeobs_autoconfig.hpp +++ b/obs-studio-client/source/nodeobs_autoconfig.hpp @@ -16,55 +16,14 @@ ******************************************************************************/ #pragma once -#include -#include -#include "utility-v8.hpp" -#ifdef WIN32 -#include -#else -#include -#endif - -struct AutoConfigInfo { - std::string event; - std::string description; - double percentage = 0; - // Optional JSON payload for new event types (bandwidth_result, - // selection_decision, video_decision, encoder_detection). Empty for legacy - // events. Surfaced to JS as a "payload" property when non-empty. - std::string payload; -}; -extern const char *ac_sem_name; -#ifdef WIN32 -extern HANDLE ac_sem; -#else -extern sem_t *ac_sem; -#endif +#include namespace autoConfig { -extern bool isWorkerRunning; -extern bool worker_stop; -extern std::chrono::milliseconds sleepInterval; -extern Napi::ThreadSafeFunction js_thread; -extern std::thread *worker_thread; -extern std::vector ac_queue_task_workers; - -void worker(void); -void start_worker(void); -void stop_worker(void); -void queueTask(AutoConfigInfo *data); - void Init(Napi::Env env, Napi::Object exports); -Napi::Value InitializeAutoConfig(const Napi::CallbackInfo &info); -Napi::Value StartBandwidthTest(const Napi::CallbackInfo &info); -Napi::Value StartStreamEncoderTest(const Napi::CallbackInfo &info); -Napi::Value StartRecordingEncoderTest(const Napi::CallbackInfo &info); -Napi::Value StartCheckSettings(const Napi::CallbackInfo &info); -Napi::Value StartSetDefaultSettings(const Napi::CallbackInfo &info); -Napi::Value StartSaveStreamSettings(const Napi::CallbackInfo &info); -Napi::Value StartSaveSettings(const Napi::CallbackInfo &info); -Napi::Value TerminateAutoConfig(const Napi::CallbackInfo &info); -Napi::Value GetAutoConfigSummary(const Napi::CallbackInfo &info); +// Stops client-side polling and releases the callback. If a session is active, +// its server-side cancellation/close is attempted while IPC is still usable. +// This is idempotent and safe to call from disconnect and environment cleanup. +void Shutdown(); } diff --git a/obs-studio-server/CMakeLists.txt b/obs-studio-server/CMakeLists.txt index 50c387f75..c8b7a1fcd 100644 --- a/obs-studio-server/CMakeLists.txt +++ b/obs-studio-server/CMakeLists.txt @@ -382,8 +382,6 @@ SET(osn-server_SOURCES "${PROJECT_SOURCE_DIR}/source/nodeobs_audio_encoders.h" "${PROJECT_SOURCE_DIR}/source/nodeobs_autoconfig.cpp" "${PROJECT_SOURCE_DIR}/source/nodeobs_autoconfig.h" - "${PROJECT_SOURCE_DIR}/source/nodeobs_autoconfig_resource_sampler.cpp" - "${PROJECT_SOURCE_DIR}/source/nodeobs_autoconfig_resource_sampler.h" "${PROJECT_SOURCE_DIR}/source/nodeobs_configManager.cpp" "${PROJECT_SOURCE_DIR}/source/nodeobs_configManager.hpp" "${PROJECT_SOURCE_DIR}/source/nodeobs_display.cpp" diff --git a/obs-studio-server/source/nodeobs_api.cpp b/obs-studio-server/source/nodeobs_api.cpp index 9ed4552ab..6eea8440f 100644 --- a/obs-studio-server/source/nodeobs_api.cpp +++ b/obs-studio-server/source/nodeobs_api.cpp @@ -1663,7 +1663,7 @@ void OBS_API::destroyOBS_API(void) #endif OBS_content::OBS_content_shutdownDisplays(); - autoConfig::WaitPendingTests(); + autoConfig::Shutdown(); OBS_service::stopAllOutputs(); OBS_service::waitReleaseWorker(); diff --git a/obs-studio-server/source/nodeobs_autoconfig.cpp b/obs-studio-server/source/nodeobs_autoconfig.cpp index 748060e98..bc151bcfd 100644 --- a/obs-studio-server/source/nodeobs_autoconfig.cpp +++ b/obs-studio-server/source/nodeobs_autoconfig.cpp @@ -1,2406 +1,1812 @@ /****************************************************************************** - Copyright (C) 2016-2019 by Streamlabs (General Workings Inc) + Copyright (C) 2026 by Streamlabs This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 2 of the License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - ******************************************************************************/ #include "nodeobs_autoconfig.h" -#include "nodeobs_autoconfig_resource_sampler.h" -#include -#include -#include -#include -#include + +#include "osn-encoders.hpp" #include "osn-error.hpp" #include "shared.hpp" -#include "osn-encoders.hpp" -#include -#include - -#include "osn-service.hpp" -#include "osn-simple-streaming.hpp" -#include "osn-advanced-streaming.hpp" -#include "osn-streaming-helpers.hpp" -#include "osn-recording.hpp" -#include "osn-video.hpp" -#include - -enum class Type { Invalid, Streaming, Recording }; -enum class Service { Twitch, Hitbox, Beam, YouTube, Other }; +#include +#include +#include +#include +#include -enum class Encoder { x264, NVENC, QSV, AMD, Apple, Stream }; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace autoConfig { +namespace { + +constexpr int kSchemaVersion = 1; +constexpr int kProbeConnectTimeoutMs = 8000; +constexpr int kProbeWarmupMs = 750; +constexpr int kProbeSampleMs = 5000; +constexpr int kProbeStopTimeoutMs = 3000; +constexpr int kCancelTimeoutMs = 8000; +constexpr uint64_t kProbeMaxBytes = 25ULL * 1024ULL * 1024ULL; +constexpr int kProbeMaximumBitrateKbps = 10000; +constexpr int kDefaultEstimatedBitrateKbps = 2500; +constexpr int kHardwareWarmupMs = 500; +constexpr int kHardwareSampleMs = 1500; +constexpr int kHardwareStopTimeoutMs = 1000; +constexpr int kHardwarePhaseTimeoutMs = 12000; +constexpr int kHardwareMaximumLongEdge = 1920; +constexpr int kHardwareMaximumShortEdge = 1080; + +enum class SessionState { Created, Running, Complete, Cancelled, Failed, Closed }; + +struct Limits { + int maxBitrateKbps = 0; + int maxWidth = 0; + int maxHeight = 0; + int maxFpsNum = 0; + int maxFpsDen = 0; + + bool any() const { return maxBitrateKbps > 0 || maxWidth > 0 || maxHeight > 0 || maxFpsNum > 0; } +}; -// Forward decl — defined further down. Needed by GetAutoConfigSummary and the -// TestStreamEncoderThread encoder_detection event push, both of which sit above -// the definition site. -static inline const char *GetEncoderId(Encoder enc); +struct CurrentSettings { + int width = 0; + int height = 0; + int fpsNum = 0; + int fpsDen = 1; + int bitrateKbps = 0; + std::string encoderId; + std::string codec; + std::string preset; +}; -enum class Quality { Stream, High }; +struct EncoderSelection { + std::string id; + bool replaced = false; +}; -enum class FPSType : int { PreferHighFPS, PreferHighRes, UseCurrent, fps30, fps60 }; +struct HardwareAssessment { + bool attempted = false; + bool passed = false; + bool cancelled = false; + bool constrained = false; + std::string reason; + CurrentSettings value; +}; -enum ThreadedTests : int { BandwidthTest, StreamEncoderTest, RecordingEncoderTest, SaveStreamSettings, SaveSettings, SetDefaultSettings, Count }; +struct Destination { + std::string platform; +}; -class AutoConfigInfo { -public: - AutoConfigInfo(const std::string &a_event, const std::string &a_description, double a_percentage, const std::string &a_payload = "") - { - event = a_event; - description = a_description; - percentage = a_percentage; - payload = a_payload; - }; - ~AutoConfigInfo(){}; - - std::string event; - std::string description; - double percentage; - // Optional JSON payload for new event types (bandwidth_result, selection_decision, - // video_decision, encoder_detection). Legacy events leave it empty. Surfaced as - // the 5th rval of Query() — legacy frontends read 4 fields and ignore it. - std::string payload; +struct LegRequest { + std::string legId; + std::string display; + std::vector destinations; + CurrentSettings current; + Limits limits; + std::string estimateReason; }; -std::array, ThreadedTests::Count> asyncTests; -std::mutex eventsMutex; -std::queue events; - -// Per-run context. One autoconfig run at a time. Targets are passed in by the -// frontend via InitializeAutoConfig; chosen values are stored here as each stage -// runs and are pushed to the live objects in applyResults(). -struct AutoconfigRun { - // Streaming targets the frontend asked us to run autoconfig against. - // Populated from InitializeAutoConfig's argument and consumed by the - // bandwidth test / apply phase. Empty means no targets were provided. - std::vector targetStreamingIds; - - // Inputs / options. - Type type = Type::Streaming; - FPSType fpsType = FPSType::PreferHighFPS; - bool preferHardware = true; - bool preferHighFPS = true; - bool bandwidthTest = true; - bool customServer = false; - int specificFPSNum = 0; - int specificFPSDen = 0; - uint64_t baseResolutionCX = 1920; - uint64_t baseResolutionCY = 1080; - int startingBitrate = 4500; - - // Detected encoder availability (filled by TestHardwareEncoding). - bool hardwareEncodingAvailable = false; - bool nvencAvailable = false; - bool qsvAvailable = false; - bool vceAvailable = false; - bool appleAvailable = false; - bool softwareTested = false; - - // Chosen values (filled by each test stage; consumed by applyResults). - Quality recordingQuality = Quality::Stream; - Encoder recordingEncoder = Encoder::Stream; - Encoder streamingEncoder = Encoder::x264; - uint64_t idealBitrate = 4500; - uint64_t idealResolutionCX = 1280; - uint64_t idealResolutionCY = 720; - int idealFPSNum = 60; - int idealFPSDen = 1; +struct ProbeRequest { + bool present = false; + std::string kind; + std::string legId; + std::string serviceName; std::string server; - - struct TargetResult { - uint64_t streamingId = UINT64_MAX; - uint64_t idealBitrate = 0; - std::string server; - }; - std::vector targetResults; - - // Per-target bandwidth-test diagnostics. Captured in TestBandwidthThreadV2 - // and surfaced via the bandwidth_result event + GetAutoConfigSummary IPC. - struct BandwidthDetail { - uint64_t targetId = UINT64_MAX; - int testBitrate = 0; - int platformCapProbed = 0; - uint64_t measuredKbps = 0; - int droppedFrames = 0; - int totalFrames = 0; - uint64_t totalBytes = 0; - int elapsedMs = 0; - std::string serverTested; - }; - std::vector bandwidthDetails; - - // Per-target selection breakdown. Captured in applyResults; surfaced via the - // selection_decision event + summary IPC. - struct SelectionDetail { - uint64_t targetId = UINT64_MAX; - int userBitrate = 0; - uint64_t heuristic = 0; - uint64_t choseBeforeCaps = 0; - uint64_t afterMeasuredCap = 0; - uint64_t afterPlatformCap = 0; - uint64_t picked = 0; - std::string bindingCap; - std::string appliedServer; - std::string currentEncoderId; - std::string chosenEncoderId; - bool encoderChanged = false; - }; - std::vector selectionDetails; - - // Per-phase resource samples (CPU%, process RAM, optionally GPU VRAM). - // Captured by ResourceSampler around the bandwidth and encoder test phases; - // surfaced via the resource_usage event + summary IPC. Pure telemetry — no - // influence on the selection heuristics in this version. - std::vector resourceWindows; - - // Per-canvas video-context decision. Captured in applyResults. - struct VideoDecision { - void *contextPtr = nullptr; - uint32_t cxBefore = 0, cyBefore = 0; - uint32_t fpsNumBefore = 0, fpsDenBefore = 0; - uint32_t cxAfter = 0, cyAfter = 0; - uint32_t fpsNumAfter = 0, fpsDenAfter = 0; - int obsSetVideoInfoRet = 0; - bool skipped = false; - }; - std::vector videoDecisions; - - // True once SaveSettings() finished. GetAutoConfigSummary uses it to set the - // JSON's "complete" flag so the POC UI can tell whether the data is final. - bool runComplete = false; + std::string streamKey; }; -static AutoconfigRun runContext; +struct Recommendation { + std::string legId; + std::string display; + std::vector destinations; + Limits limits; + std::string measurementMode = "estimated"; + std::string confidence = "medium"; + std::string reason; + CurrentSettings value; +}; -std::condition_variable cv; -std::mutex m; -bool cancel = false; -bool started = false; +struct SessionEvent { + uint64_t sequence = 0; + std::string type; + std::string phase; + double progress = 0; + std::string code; + std::string legId; + std::string measurementMode; +}; -struct ServerInfo { - std::string name; - std::string address; - int bitrate = 0; - int ms = -1; - size_t targetIndex = 0; +struct Session : std::enable_shared_from_this { + std::string id; + std::string topology; + std::vector legs; + ProbeRequest probe; + bool activeProbeEligible = false; + std::string activeProbeDenialReason; + + std::atomic state{SessionState::Created}; + std::atomic cancelRequested{false}; + // Serializes creation and inspection of worker. IPC calls may arrive from + // different client connections, so the atomic state alone is not sufficient + // to protect std::future from concurrent assignment/wait operations. + std::mutex lifecycleMutex; + std::future worker; + + std::mutex mutex; + uint64_t nextSequence = 1; + std::queue events; + std::string resultJson; + + // The worker owns this output. Cancel only borrows it while holding this + // mutex, so it can request a force-stop without racing release. + std::mutex probeMutex; + obs_output_t *activeProbeOutput = nullptr; +}; - inline ServerInfo() {} +std::mutex sessionsMutex; +std::shared_ptr activeSession; +std::atomic nextSessionId{1}; +std::atomic shuttingDown{false}; - inline ServerInfo(const char *name_, const char *address_) : name(name_), address(address_) {} -}; -void autoConfig::Register(ipc::server &srv) +static void returnError(std::vector &rval, const char *message) { - std::shared_ptr cls = std::make_shared("AutoConfig"); - - cls->register_function( - std::make_shared("InitializeAutoConfig", std::vector{ipc::type::Binary}, autoConfig::InitializeAutoConfig)); - cls->register_function(std::make_shared("StartBandwidthTest", std::vector{}, autoConfig::StartBandwidthTest)); - cls->register_function(std::make_shared("StartStreamEncoderTest", std::vector{}, autoConfig::StartStreamEncoderTest)); - cls->register_function(std::make_shared("StartRecordingEncoderTest", std::vector{}, autoConfig::StartRecordingEncoderTest)); - cls->register_function(std::make_shared("StartCheckSettings", std::vector{}, autoConfig::StartCheckSettings)); - cls->register_function(std::make_shared("StartSetDefaultSettings", std::vector{}, autoConfig::StartSetDefaultSettings)); - cls->register_function(std::make_shared("StartSaveStreamSettings", std::vector{}, autoConfig::StartSaveStreamSettings)); - cls->register_function(std::make_shared("StartSaveSettings", std::vector{}, autoConfig::StartSaveSettings)); - cls->register_function(std::make_shared("TerminateAutoConfig", std::vector{}, autoConfig::TerminateAutoConfig)); - cls->register_function(std::make_shared("Query", std::vector{}, autoConfig::Query)); - cls->register_function(std::make_shared("GetAutoConfigSummary", std::vector{}, autoConfig::GetAutoConfigSummary)); - - srv.register_collection(cls); + rval.push_back(ipc::value((uint64_t)ErrorCode::Error)); + rval.push_back(ipc::value(message)); } -void autoConfig::WaitPendingTests(double timeout) +static std::string lowerCopy(std::string value) { - clock_t start_time = clock(); - while ((float(clock() - start_time) / CLOCKS_PER_SEC) < timeout) { - - bool all_finished = true; - for (auto &async_test : asyncTests) { - if (async_test.valid()) { - auto status = async_test.wait_for(std::chrono::milliseconds(0)); - if (status != std::future_status::ready) { - all_finished = false; - } - } - } - - if (all_finished) - break; + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { return (char)std::tolower(ch); }); + return value; +} - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } +static bool hasSuffix(const std::string &value, const std::string &suffix) +{ + return value.size() >= suffix.size() && value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; } -// Serialize a ResourceWindow to JSON and emit a resource_usage event. The window -// is also pushed onto runContext.resourceWindows so GetAutoConfigSummary can -// re-emit it later. Frontends can consume either the event stream or the summary. -static std::string resourceWindowToJson(const autoConfig::ResourceWindow &w) +static bool isOfficialTwitchServer(const std::string &server) { - obs_data_t *root = obs_data_create(); - obs_data_set_string(root, "phase", w.phase.c_str()); - obs_data_set_int(root, "sampleCount", w.sampleCount); - obs_data_set_int(root, "durationMs", w.durationMs); - - // p50 is the typical value during the window; p95 is the sustained ceiling - // after dropping single-sample outliers (a background process briefly using - // CPU shouldn't dominate the report). - auto putPct = [&](const char *key, double p50, double p95) { - obs_data_t *o = obs_data_create(); - obs_data_set_double(o, "p50", p50); - obs_data_set_double(o, "p95", p95); - obs_data_set_obj(root, key, o); - obs_data_release(o); - }; - auto putPctInt = [&](obs_data_t *parent, const char *key, uint64_t p50, uint64_t p95) { - obs_data_t *o = obs_data_create(); - obs_data_set_int(o, "p50", (long long)p50); - obs_data_set_int(o, "p95", (long long)p95); - obs_data_set_obj(parent, key, o); - obs_data_release(o); - }; + std::string value = lowerCopy(server); + if (value == "auto") + return true; - putPct("cpuPct", w.p50Sample.cpuPct, w.p95Sample.cpuPct); - putPct("procRamMB", w.p50Sample.procRamMB, w.p95Sample.procRamMB); + const size_t scheme = value.find("://"); + if (scheme == std::string::npos || (value.compare(0, 7, "rtmp://") != 0 && value.compare(0, 8, "rtmps://") != 0)) + return false; - obs_data_t *gpu = obs_data_create(); - obs_data_set_bool(gpu, "available", w.gpuAvailable); - if (w.gpuAvailable) { - putPctInt(gpu, "vramUsedMB", w.p50Sample.gpuVramUsedMB, w.p95Sample.gpuVramUsedMB); - // Budget is platform-driven and effectively constant across a window — - // surface a single number rather than a percentile pair. - obs_data_set_int(gpu, "vramBudgetMB", (long long)w.p95Sample.gpuVramBudgetMB); - } - obs_data_set_obj(root, "gpu", gpu); - obs_data_release(gpu); + const size_t hostStart = scheme + 3; + const size_t hostEnd = value.find_first_of("/:?#", hostStart); + const std::string host = value.substr(hostStart, hostEnd == std::string::npos ? std::string::npos : hostEnd - hostStart); + if (host.empty() || host.find('@') != std::string::npos) + return false; - std::string json = obs_data_get_json(root); - obs_data_release(root); - return json; + return host == "live.twitch.tv" || hasSuffix(host, ".twitch.tv") || host == "live-video.net" || hasSuffix(host, ".live-video.net"); } -static void recordResourceWindow(const autoConfig::ResourceWindow &w) +static void trim(std::string &value) { - if (w.sampleCount <= 0) - return; - - runContext.resourceWindows.push_back(w); - - std::string payload = resourceWindowToJson(w); - std::lock_guard lock(eventsMutex); - events.push(AutoConfigInfo("resource_usage", w.phase, 100, payload)); + while (!value.empty() && std::isspace((unsigned char)value.back())) + value.pop_back(); + size_t first = 0; + while (first < value.size() && std::isspace((unsigned char)value[first])) + first++; + if (first) + value.erase(0, first); } -void autoConfig::TestHardwareEncoding(void) +static std::string normalizeTwitchBandwidthKey(std::string key) { - size_t idx = 0; - const char *id; - while (obs_enum_encoder_types(idx++, &id)) { - if (strcmp(id, ADVANCED_ENCODER_NVENC) == 0) - runContext.hardwareEncodingAvailable = runContext.nvencAvailable = true; - else if (strcmp(id, ADVANCED_ENCODER_QSV) == 0) - runContext.hardwareEncodingAvailable = runContext.qsvAvailable = true; - else if (strcmp(id, ADVANCED_ENCODER_AMD) == 0) - runContext.hardwareEncodingAvailable = runContext.vceAvailable = true; -#ifdef __APPLE__ - else if (strcmp(id, APPLE_HARDWARE_VIDEO_ENCODER_M1) == 0 -#ifndef __aarch64__ - && os_get_emulation_status() == true -#endif - ) - if (__builtin_available(macOS 13.0, *)) - runContext.hardwareEncodingAvailable = runContext.appleAvailable = true; -#endif + trim(key); + const size_t queryPos = key.find('?'); + const std::string base = key.substr(0, queryPos); + std::vector retained; + + if (queryPos != std::string::npos) { + std::string query = key.substr(queryPos + 1); + size_t offset = 0; + while (offset <= query.size()) { + const size_t next = query.find('&', offset); + std::string item = query.substr(offset, next == std::string::npos ? std::string::npos : next - offset); + const size_t equals = item.find('='); + const std::string name = lowerCopy(item.substr(0, equals)); + if (!item.empty() && name != "bandwidthtest") + retained.push_back(item); + if (next == std::string::npos) + break; + offset = next + 1; + } + } + + std::string result = base + "?"; + for (const auto &item : retained) { + result += item; + result += "&"; } + result += "bandwidthtest=true"; + return result; } -static inline void string_depad_key(std::string &key) +static std::string defaultEstimateReason(const std::string &topology, const LegRequest &leg) { - while (!key.empty()) { - char ch = key.back(); - if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') - key.pop_back(); - else - break; - } + if (!leg.estimateReason.empty()) + return leg.estimateReason; + if (topology == "custom-rtmp") + return "custom_rtmp"; + if (topology == "cloud-multistream") + return "cloud_multistream"; + if (topology == "dual-output") + return "dual_output"; + if (topology == "enhanced-broadcasting") + return "enhanced_broadcasting"; + if (topology == "stream-shift") + return "stream_shift"; + if (topology == "mixed") + return "mixed_topology"; + return "non_twitch"; } -// bool autoConfig::CanTestServer(const char *server) -// { -// if (!testRegions || (regionNA && regionSA && regionEU && regionAS && regionOC)) -// return true; - -// if (serviceSelected == Service::Twitch) { -// if (astrcmp_n(server, "NA:", 3) == 0 || astrcmp_n(server, "US West:", 8) == 0 || astrcmp_n(server, "US East:", 8) == 0 || -// astrcmp_n(server, "US Central:", 11) == 0) { -// return regionNA; -// } else if (astrcmp_n(server, "South America:", 14) == 0) { -// return regionSA; -// } else if (astrcmp_n(server, "EU:", 3) == 0) { -// return regionEU; -// } else if (astrcmp_n(server, "Asia:", 5) == 0) { -// return regionAS; -// } else if (astrcmp_n(server, "Australia:", 10) == 0) { -// return regionOC; -// } else { -// return true; -// } -// } else if (serviceSelected == Service::Hitbox) { -// if (strcmp(server, "Default") == 0) { -// return true; -// } else if (astrcmp_n(server, "US-West:", 8) == 0 || astrcmp_n(server, "US-East:", 8) == 0) { -// return regionNA; -// } else if (astrcmp_n(server, "South America:", 14) == 0) { -// return regionSA; -// } else if (astrcmp_n(server, "EU-", 3) == 0) { -// return regionEU; -// } else if (astrcmp_n(server, "South Korea:", 12) == 0 || astrcmp_n(server, "Asia:", 5) == 0 || astrcmp_n(server, "China:", 6) == 0) { -// return regionAS; -// } else if (astrcmp_n(server, "Oceania:", 8) == 0) { -// return regionOC; -// } else { -// return true; -// } -// } else if (serviceSelected == Service::Beam) { -// if (astrcmp_n(server, "US:", 3) == 0 || astrcmp_n(server, "Canada:", 7) || astrcmp_n(server, "Mexico:", 7)) { -// return regionNA; -// } else if (astrcmp_n(server, "Brazil:", 7) == 0) { -// return regionSA; -// } else if (astrcmp_n(server, "EU:", 3) == 0) { -// return regionEU; -// } else if (astrcmp_n(server, "South Korea:", 12) == 0 || astrcmp_n(server, "Asia:", 5) == 0 || astrcmp_n(server, "India:", 6) == 0) { -// return regionAS; -// } else if (astrcmp_n(server, "Australia:", 10) == 0) { -// return regionOC; -// } else { -// return true; -// } -// } else { -// return true; -// } - -// return false; -// } - -// void GetServers(std::vector &servers) -// { -// OBSData settings = obs_data_create(); -// obs_data_release(settings); -// // obs_data_set_string(settings, "service", wiz->serviceName.c_str()); -// //FIX ME -// obs_data_set_string(settings, "service", serviceName.c_str()); - -// obs_properties_t *ppts = obs_get_service_properties("rtmp_common"); -// obs_property_t *p = obs_properties_get(ppts, "service"); -// obs_property_modified(p, settings); - -// p = obs_properties_get(ppts, "server"); -// size_t count = obs_property_list_item_count(p); -// servers.reserve(count); - -// for (size_t i = 0; i < count; i++) { -// const char *name = obs_property_list_item_name(p, i); -// const char *server = obs_property_list_item_string(p, i); - -// if (autoConfig::CanTestServer(name)) { -// ServerInfo info(name, server); -// servers.push_back(info); -// } -// } - -// obs_properties_destroy(ppts); -// } - -void start_next_step(void (*task)(), std::string event, std::string description, int percentage) +static bool isKnownDisplay(const std::string &display) { - /*eventCallbackQueue.work_queue.push_back({cb, event, description, percentage}); - eventCallbackQueue.Signal(); + return display == "horizontal" || display == "vertical" || display == "both"; +} - if(task) - std::thread(*task).detach();*/ +static bool isKnownPlatform(const std::string &platform) +{ + static const std::set known = {"twitch", "youtube", "facebook", "kick", "tiktok", "custom", "other"}; + return known.count(platform) != 0; } -void autoConfig::TerminateAutoConfig(void *data, const int64_t id, const std::vector &args, std::vector &rval) +static bool isKnownTopology(const std::string &topology) { - StopThread(); - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - AUTO_DEBUG; + static const std::set known = {"direct-single", "cloud-multistream", "custom-rtmp", "dual-output", + "enhanced-broadcasting", "stream-shift", "mixed"}; + return known.count(topology) != 0; } -void autoConfig::Query(void *data, const int64_t id, const std::vector &args, std::vector &rval) +static bool parseRequest(const std::string &json, Session &session, std::string &error) { - std::unique_lock ulock(eventsMutex); - if (events.empty()) { - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - AUTO_DEBUG; - return; + obs_data_t *root = obs_data_create_from_json(json.c_str()); + if (!root) { + error = "invalid_autoconfig_request_json"; + return false; } - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - - rval.push_back(ipc::value(events.front().event)); - rval.push_back(ipc::value(events.front().description)); - rval.push_back(ipc::value(events.front().percentage)); - // 5th field: optional JSON payload for new event types. Empty for legacy - // events. Legacy frontends read 4 fields and ignore this one. - rval.push_back(ipc::value(events.front().payload)); + bool valid = true; + if ((int)obs_data_get_int(root, "schemaVersion") != kSchemaVersion) { + error = "unsupported_autoconfig_schema"; + valid = false; + } - events.pop(); + session.topology = obs_data_get_string(root, "topology"); + if (valid && !isKnownTopology(session.topology)) { + error = "invalid_autoconfig_topology"; + valid = false; + } - AUTO_DEBUG; -} + obs_data_array_t *legs = obs_data_get_array(root, "legs"); + const size_t legCount = legs ? obs_data_array_count(legs) : 0; + if (valid && (legCount == 0 || legCount > 8)) { + error = "invalid_autoconfig_legs"; + valid = false; + } -void autoConfig::GetAutoConfigSummary(void *data, const int64_t id, const std::vector &args, std::vector &rval) -{ - // Build a structured JSON summary of the most recent autoconfig run for the - // new POC UI. Safe to call before `done` — `complete` flag indicates whether - // the data is final. Reset to empty on InitializeAutoConfig. - obs_data_t *root = obs_data_create(); - obs_data_set_bool(root, "complete", runContext.runComplete); + std::set legIds; + for (size_t i = 0; valid && i < legCount; i++) { + obs_data_t *item = obs_data_array_item(legs, i); + LegRequest leg; + leg.legId = obs_data_get_string(item, "legId"); + leg.display = obs_data_get_string(item, "display"); + leg.estimateReason = obs_data_get_string(item, "estimateReason"); - // encoderDetection - { - const char *chosenStream = GetEncoderId(runContext.streamingEncoder); - const char *chosenRecording = GetEncoderId(runContext.recordingEncoder); - obs_data_t *enc = obs_data_create(); - obs_data_set_bool(enc, "hardwareEncodingAvailable", runContext.hardwareEncodingAvailable); - obs_data_set_bool(enc, "nvenc", runContext.nvencAvailable); - obs_data_set_bool(enc, "qsv", runContext.qsvAvailable); - obs_data_set_bool(enc, "vce", runContext.vceAvailable); - obs_data_set_bool(enc, "apple", runContext.appleAvailable); - obs_data_set_bool(enc, "softwareTested", runContext.softwareTested); - obs_data_set_string(enc, "chosenStreamingEncoder", chosenStream ? chosenStream : ""); - obs_data_set_string(enc, "chosenRecordingEncoder", chosenRecording ? chosenRecording : ""); - obs_data_set_string(enc, "recordingQuality", runContext.recordingQuality == Quality::High ? "High" : "Stream"); - obs_data_set_obj(root, "encoderDetection", enc); - obs_data_release(enc); - } - - // videoDecision - { - obs_data_t *video = obs_data_create(); - obs_data_t *chosen = obs_data_create(); - obs_data_set_int(chosen, "cx", (long long)runContext.idealResolutionCX); - obs_data_set_int(chosen, "cy", (long long)runContext.idealResolutionCY); - obs_data_set_int(chosen, "fpsNum", runContext.idealFPSNum); - obs_data_set_int(chosen, "fpsDen", runContext.idealFPSDen); - obs_data_set_obj(video, "chosen", chosen); - obs_data_release(chosen); - - obs_data_array_t *perCanvas = obs_data_array_create(); - for (auto &vd : runContext.videoDecisions) { - obs_data_t *item = obs_data_create(); - std::ostringstream ptrOss; - ptrOss << "0x" << std::hex << reinterpret_cast(vd.contextPtr); - obs_data_set_string(item, "contextPtr", ptrOss.str().c_str()); - - obs_data_t *before = obs_data_create(); - obs_data_set_int(before, "cx", vd.cxBefore); - obs_data_set_int(before, "cy", vd.cyBefore); - obs_data_set_int(before, "fpsNum", vd.fpsNumBefore); - obs_data_set_int(before, "fpsDen", vd.fpsDenBefore); - obs_data_set_obj(item, "before", before); - obs_data_release(before); - - obs_data_t *after = obs_data_create(); - obs_data_set_int(after, "cx", vd.cxAfter); - obs_data_set_int(after, "cy", vd.cyAfter); - obs_data_set_int(after, "fpsNum", vd.fpsNumAfter); - obs_data_set_int(after, "fpsDen", vd.fpsDenAfter); - obs_data_set_obj(item, "after", after); - obs_data_release(after); - - obs_data_set_int(item, "obsSetVideoInfoRet", vd.obsSetVideoInfoRet); - obs_data_set_bool(item, "skipped", vd.skipped); - obs_data_array_push_back(perCanvas, item); - obs_data_release(item); + if (leg.legId.empty() || leg.legId.size() > 128 || !legIds.insert(leg.legId).second || !isKnownDisplay(leg.display)) { + error = "invalid_autoconfig_leg_identity"; + valid = false; } - obs_data_set_array(video, "perCanvas", perCanvas); - obs_data_array_release(perCanvas); - obs_data_set_obj(root, "videoDecision", video); - obs_data_release(video); - } - - // bandwidthTest - { - obs_data_t *bw = obs_data_create(); - obs_data_array_t *perTarget = obs_data_array_create(); - for (auto &bd : runContext.bandwidthDetails) { - obs_data_t *item = obs_data_create(); - obs_data_set_int(item, "targetId", (long long)bd.targetId); - obs_data_set_int(item, "testBitrate", bd.testBitrate); - obs_data_set_int(item, "platformCapProbed", bd.platformCapProbed); - obs_data_set_int(item, "measuredKbps", (long long)bd.measuredKbps); - obs_data_set_int(item, "droppedFrames", bd.droppedFrames); - obs_data_set_int(item, "totalFrames", bd.totalFrames); - obs_data_set_int(item, "totalBytes", (long long)bd.totalBytes); - obs_data_set_int(item, "elapsedMs", bd.elapsedMs); - obs_data_set_string(item, "serverTested", bd.serverTested.c_str()); - obs_data_array_push_back(perTarget, item); - obs_data_release(item); + obs_data_t *current = obs_data_get_obj(item, "current"); + if (!current) { + error = "missing_autoconfig_current_settings"; + valid = false; + } else { + leg.current.width = (int)obs_data_get_int(current, "width"); + leg.current.height = (int)obs_data_get_int(current, "height"); + leg.current.fpsNum = (int)obs_data_get_int(current, "fpsNum"); + leg.current.fpsDen = (int)obs_data_get_int(current, "fpsDen"); + leg.current.bitrateKbps = (int)obs_data_get_int(current, "bitrateKbps"); + leg.current.encoderId = obs_data_get_string(current, "encoderId"); + leg.current.codec = obs_data_get_string(current, "codec"); + leg.current.preset = obs_data_get_string(current, "preset"); + if (leg.current.width < 64 || leg.current.width > 8192 || leg.current.height < 64 || leg.current.height > 8192 || + leg.current.fpsNum <= 0 || leg.current.fpsNum > 240000 || leg.current.fpsDen <= 0 || leg.current.fpsDen > 10000 || + leg.current.bitrateKbps < 0 || leg.current.bitrateKbps > 100000) { + error = "invalid_autoconfig_current_settings"; + valid = false; + } + obs_data_release(current); } - obs_data_set_array(bw, "perTarget", perTarget); - obs_data_array_release(perTarget); - - obs_data_set_obj(root, "bandwidthTest", bw); - obs_data_release(bw); - } - // selection - { - obs_data_t *sel = obs_data_create(); - obs_data_array_t *perTarget = obs_data_array_create(); - for (auto &sd : runContext.selectionDetails) { - obs_data_t *item = obs_data_create(); - obs_data_set_int(item, "targetId", (long long)sd.targetId); - obs_data_set_int(item, "userBitrate", sd.userBitrate); - obs_data_set_int(item, "heuristic", (long long)sd.heuristic); - obs_data_set_int(item, "choseBeforeCaps", (long long)sd.choseBeforeCaps); - obs_data_set_int(item, "afterMeasuredCap", (long long)sd.afterMeasuredCap); - obs_data_set_int(item, "afterPlatformCap", (long long)sd.afterPlatformCap); - obs_data_set_int(item, "picked", (long long)sd.picked); - obs_data_set_string(item, "bindingCap", sd.bindingCap.c_str()); - obs_data_set_string(item, "appliedServer", sd.appliedServer.c_str()); - obs_data_set_string(item, "currentEncoderId", sd.currentEncoderId.c_str()); - obs_data_set_string(item, "chosenEncoderId", sd.chosenEncoderId.c_str()); - obs_data_set_bool(item, "encoderChanged", sd.encoderChanged); - obs_data_array_push_back(perTarget, item); - obs_data_release(item); + obs_data_t *limits = obs_data_get_obj(item, "limits"); + if (limits) { + leg.limits.maxBitrateKbps = (int)obs_data_get_int(limits, "maxBitrateKbps"); + leg.limits.maxWidth = (int)obs_data_get_int(limits, "maxWidth"); + leg.limits.maxHeight = (int)obs_data_get_int(limits, "maxHeight"); + leg.limits.maxFpsNum = (int)obs_data_get_int(limits, "maxFpsNum"); + leg.limits.maxFpsDen = (int)obs_data_get_int(limits, "maxFpsDen"); + if (leg.limits.maxFpsNum > 0 && leg.limits.maxFpsDen <= 0) + leg.limits.maxFpsDen = 1; + obs_data_release(limits); } - obs_data_set_array(sel, "perTarget", perTarget); - obs_data_array_release(perTarget); - obs_data_set_obj(root, "selection", sel); - obs_data_release(sel); - } - - // resourceUsage — per-phase CPU/RAM (and Windows-only GPU VRAM) samples - // captured during the bandwidth and encoder test phases. Same JSON shape - // as the resource_usage event payload. - { - obs_data_array_t *windows = obs_data_array_create(); - for (auto &w : runContext.resourceWindows) { - std::string s = resourceWindowToJson(w); - obs_data_t *item = obs_data_create_from_json(s.c_str()); - if (item) { - obs_data_array_push_back(windows, item); - obs_data_release(item); + obs_data_array_t *destinations = obs_data_get_array(item, "destinations"); + const size_t destinationCount = destinations ? obs_data_array_count(destinations) : 0; + if (destinationCount == 0 || destinationCount > 16) { + error = "invalid_autoconfig_destinations"; + valid = false; + } else { + for (size_t di = 0; di < destinationCount; di++) { + obs_data_t *destination = obs_data_array_item(destinations, di); + Destination parsed{lowerCopy(obs_data_get_string(destination, "platform"))}; + obs_data_release(destination); + if (!isKnownPlatform(parsed.platform)) { + error = "invalid_autoconfig_platform"; + valid = false; + break; + } + leg.destinations.push_back(std::move(parsed)); } } - obs_data_set_array(root, "resourceUsage", windows); - obs_data_array_release(windows); - } - - std::string json = obs_data_get_json_pretty(root); + if (destinations) + obs_data_array_release(destinations); + + obs_data_release(item); + if (valid) + session.legs.push_back(std::move(leg)); + } + if (legs) + obs_data_array_release(legs); + + obs_data_t *probe = obs_data_get_obj(root, "activeProbe"); + if (valid && probe) { + session.probe.present = true; + session.probe.kind = obs_data_get_string(probe, "kind"); + session.probe.legId = obs_data_get_string(probe, "legId"); + session.probe.serviceName = obs_data_get_string(probe, "serviceName"); + session.probe.server = obs_data_get_string(probe, "server"); + session.probe.streamKey = obs_data_get_string(probe, "streamKey"); + } + if (probe) + obs_data_release(probe); obs_data_release(root); - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - rval.push_back(ipc::value(json)); - AUTO_DEBUG; + if (!valid) + return false; + + // Active probing is deliberately default-deny. A request that does not meet + // every invariant remains usable, but all legs are estimated and the secret + // is discarded before any network object can be created. + session.activeProbeEligible = + session.probe.present && session.topology == "direct-single" && session.legs.size() == 1 && session.legs[0].destinations.size() == 1 && + session.legs[0].destinations[0].platform == "twitch" && session.probe.kind == "twitch-standard-v1" && session.probe.serviceName == "Twitch" && + session.probe.legId == session.legs[0].legId && !session.probe.streamKey.empty() && isOfficialTwitchServer(session.probe.server); + if (session.probe.present && !session.activeProbeEligible) { + session.activeProbeDenialReason = "active_probe_not_eligible"; + session.probe.streamKey.clear(); + session.probe.server.clear(); + } + + return true; } -void autoConfig::StopThread(void) +static void pushEvent(const std::shared_ptr &session, const char *type, const char *phase, double progress, const std::string &code = {}, + const std::string &legId = {}, const std::string &measurementMode = {}) { - std::unique_lock ul(m); - cancel = true; - cv.notify_one(); + std::lock_guard lock(session->mutex); + SessionEvent event; + event.sequence = session->nextSequence++; + event.type = type; + event.phase = phase; + event.progress = progress; + event.code = code; + event.legId = legId; + event.measurementMode = measurementMode; + session->events.push(std::move(event)); } -void autoConfig::InitializeAutoConfig(void *data, const int64_t id, const std::vector &args, std::vector &rval) +static std::shared_ptr findSession(const std::string &id) { - runContext = AutoconfigRun{}; - cancel = false; - - // Drain leftover events from a prior run. Otherwise a stopping_step queued - // by an aborted bandwidth thread (e.g. after TerminateAutoConfig) leaks into - // the next session's first drainUntil() and confuses callers. - { - std::lock_guard lock(eventsMutex); - while (!events.empty()) - events.pop(); - } - - if (!args.empty()) { - const std::vector &bin = args[0].value_bin; - size_t n = bin.size() / sizeof(uint64_t); - runContext.targetStreamingIds.resize(n); - if (n > 0) - memcpy(runContext.targetStreamingIds.data(), bin.data(), n * sizeof(uint64_t)); - } - - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - AUTO_DEBUG; + std::lock_guard lock(sessionsMutex); + if (activeSession && activeSession->id == id) + return activeSession; + return nullptr; } -void autoConfig::StartBandwidthTest(void *data, const int64_t id, const std::vector &args, std::vector &rval) +static std::string resolveEncoderId(const std::string &id) { - if (asyncTests[ThreadedTests::BandwidthTest].valid()) - asyncTests[ThreadedTests::BandwidthTest].wait(); - - { - std::lock_guard lock(eventsMutex); - while (!events.empty()) - events.pop(); + if (id.empty()) + return {}; + // The software VideoToolbox implementation is not a hardware-capacity + // signal and Desktop cannot currently apply Apple encoder-family metadata. + // Registered hardware VideoToolbox IDs are still preserved when already + // selected, but this software implementation must never be recommended. + if (id == "com.apple.videotoolbox.videoencoder.h264") + return {}; + if (obs_get_encoder_codec(id.c_str())) + return id; + for (const auto &option : osn::EncoderUtils::videoEncoderOptions) { + if (option.simple_name == id) { + const std::string internal = osn::EncoderUtils::getInternalEncoderFromSimple(id.c_str()); + return osn::EncoderUtils::isEncoderRegistered(internal) ? internal : std::string{}; + } } - - asyncTests[ThreadedTests::BandwidthTest] = std::async(std::launch::async, TestBandwidthThreadV2); - - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - AUTO_DEBUG; + return {}; } -void autoConfig::StartStreamEncoderTest(void *data, const int64_t id, const std::vector &args, std::vector &rval) +static EncoderSelection chooseEncoder(const CurrentSettings ¤t) { - asyncTests[ThreadedTests::StreamEncoderTest] = std::async(std::launch::async, TestStreamEncoderThread); + if (!resolveEncoderId(current.encoderId).empty()) + return {current.encoderId, false}; + + // Apple/VideoToolbox is intentionally not an automatic fallback. Desktop's + // current encoder metadata has no Apple family mapping, so returning one here + // would produce a recommendation it cannot apply. A currently selected and + // registered Apple encoder is still preserved by the branch above. + const char *preferred[] = {ENCODER_NVENC_H264_TEX, ADVANCED_ENCODER_QSV_V2, ADVANCED_ENCODER_QSV, ADVANCED_ENCODER_AMD, ADVANCED_ENCODER_X264}; + for (const char *candidate : preferred) { + if (candidate && osn::EncoderUtils::isEncoderRegistered(candidate)) + return {candidate, candidate != current.encoderId}; + } + return {{}, !current.encoderId.empty()}; +} - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - AUTO_DEBUG; +static std::string scratchEncoderId(const std::string &recommendationId) +{ + const std::string encoder = resolveEncoderId(recommendationId); + if (encoder == ENCODER_NVENC_H264_TEX) + return "obs_nvenc_h264_soft"; + if (encoder == ENCODER_NVENC_HEVC_TEX) + return "obs_nvenc_hevc_soft"; + if (encoder == ENCODER_NVENC_AV1_TEX) + return "obs_nvenc_av1_soft"; + if (encoder == ADVANCED_ENCODER_QSV) + return "obs_qsv11_soft"; + if (encoder == ADVANCED_ENCODER_QSV_V2) + return "obs_qsv11_soft_v2"; + if (encoder == ADVANCED_ENCODER_QSV_AV1) + return "obs_qsv11_av1_soft"; + if (encoder == ADVANCED_ENCODER_QSV_HEVC) + return "obs_qsv11_hevc_soft"; + if (encoder == ADVANCED_ENCODER_AMD) + return "h264_fallback_amf"; + if (encoder == ADVANCED_ENCODER_AMD_HEVC) + return "h265_fallback_amf"; + if (encoder == ADVANCED_ENCODER_AMD_AV1) + return "av1_fallback_amf"; + return encoder; } -void autoConfig::StartRecordingEncoderTest(void *data, const int64_t id, const std::vector &args, std::vector &rval) +static bool isX264Preset(const std::string &preset) { - asyncTests[ThreadedTests::RecordingEncoderTest] = std::async(std::launch::async, TestRecordingEncoderThread); + static const std::set supported = {"ultrafast", "superfast", "veryfast", "faster", "fast", + "medium", "slow", "slower", "veryslow", "placebo"}; + return supported.count(lowerCopy(preset)) != 0; +} - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - AUTO_DEBUG; +static int offlinePlatformCapKbps(const std::string &platform) +{ + const char *serviceName = nullptr; + if (platform == "twitch") + serviceName = "Twitch"; + else if (platform == "youtube") + serviceName = "YouTube - RTMPS"; + else if (platform == "facebook") + serviceName = "Facebook Live"; + else + return 0; + + // This only loads the bundled rtmp-services metadata and invokes its encoder + // constraints. No output is created and no DNS/network operation is possible. + obs_data_t *serviceSettings = obs_data_create(); + obs_data_set_string(serviceSettings, "service", serviceName); + obs_service_t *service = obs_service_create_private("rtmp_common", "auto_optimizer_offline_cap", serviceSettings); + obs_data_release(serviceSettings); + if (!service) + return 0; + + constexpr int probeValue = 100000; + obs_data_t *encoderSettings = obs_data_create(); + obs_data_set_int(encoderSettings, "bitrate", probeValue); + obs_service_apply_encoder_settings(service, encoderSettings, nullptr); + const int value = (int)obs_data_get_int(encoderSettings, "bitrate"); + obs_data_release(encoderSettings); + obs_service_release(service); + return value > 0 && value < probeValue ? value : 0; } -void autoConfig::StartSaveStreamSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval) +static LegRequest withOfflinePlatformCaps(const LegRequest &input) { - asyncTests[ThreadedTests::SaveStreamSettings] = std::async(std::launch::async, SaveStreamSettings); + LegRequest leg = input; + int strictest = 0; + for (const auto &destination : leg.destinations) { + const int cap = offlinePlatformCapKbps(destination.platform); + if (cap > 0 && (strictest == 0 || cap < strictest)) + strictest = cap; + } + if (strictest > 0 && (leg.limits.maxBitrateKbps == 0 || strictest < leg.limits.maxBitrateKbps)) + leg.limits.maxBitrateKbps = strictest; + return leg; +} - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - AUTO_DEBUG; +static bool fitWithin(CurrentSettings &value, int maxWidth, int maxHeight) +{ + maxWidth = std::max(64, maxWidth); + maxHeight = std::max(64, maxHeight); + const double scale = std::min({1.0, (double)maxWidth / (double)value.width, (double)maxHeight / (double)value.height}); + const int width = std::max(64, ((int)std::floor((double)value.width * scale)) & ~1); + const int height = std::max(64, ((int)std::floor((double)value.height * scale)) & ~1); + const bool changed = width != value.width || height != value.height; + value.width = width; + value.height = height; + return changed; } -void autoConfig::StartSaveSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval) +static bool capFps(CurrentSettings &value, int maxNum, int maxDen) { - asyncTests[ThreadedTests::SaveSettings] = std::async(std::launch::async, SaveSettings); + maxDen = maxDen > 0 ? maxDen : 1; + if ((int64_t)value.fpsNum * maxDen <= (int64_t)maxNum * value.fpsDen) + return false; + value.fpsNum = maxNum; + value.fpsDen = maxDen; + return true; +} - cancel = false; +static void applyEncoderSelection(CurrentSettings &value, const EncoderSelection &selection) +{ + if (selection.id != value.encoderId) { + value.encoderId = selection.id; + // Presets are encoder-family-specific. Never carry a preset from an + // unavailable/failed encoder into its replacement; encoder defaults are + // safer than a syntactically valid preset for the wrong family. + value.preset.clear(); + const std::string internal = resolveEncoderId(value.encoderId); + const char *codec = internal.empty() ? nullptr : obs_get_encoder_codec(internal.c_str()); + value.codec = codec ? codec : "h264"; + } + if (value.codec.empty()) { + const std::string internal = resolveEncoderId(value.encoderId); + const char *codec = internal.empty() ? nullptr : obs_get_encoder_codec(internal.c_str()); + value.codec = codec ? codec : "h264"; + } +} - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - AUTO_DEBUG; +static CurrentSettings baseRecommendation(const LegRequest &leg) +{ + CurrentSettings value = leg.current; + if (value.bitrateKbps <= 0) + value.bitrateKbps = kDefaultEstimatedBitrateKbps; + if (leg.limits.maxBitrateKbps > 0) + value.bitrateKbps = std::min(value.bitrateKbps, leg.limits.maxBitrateKbps); + fitWithin(value, leg.limits.maxWidth > 0 ? leg.limits.maxWidth : value.width, leg.limits.maxHeight > 0 ? leg.limits.maxHeight : value.height); + if (leg.limits.maxFpsNum > 0) + capFps(value, leg.limits.maxFpsNum, leg.limits.maxFpsDen); + applyEncoderSelection(value, chooseEncoder(value)); + return value; } -void autoConfig::StartCheckSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval) +static CurrentSettings estimateRecommendation(const LegRequest &leg, const HardwareAssessment &hardware) { - bool sucess = CheckSettings(); + CurrentSettings value = baseRecommendation(leg); + if (hardware.attempted) { + value.width = hardware.value.width; + value.height = hardware.value.height; + value.fpsNum = hardware.value.fpsNum; + value.fpsDen = hardware.value.fpsDen; + applyEncoderSelection(value, {hardware.value.encoderId, hardware.value.encoderId != value.encoderId}); + value.preset = hardware.value.preset; + } + return value; +} - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - rval.push_back(ipc::value((uint32_t)sucess)); - AUTO_DEBUG; +static void putLimits(obs_data_t *parent, const Limits &limits) +{ + if (!limits.any()) + return; + obs_data_t *obj = obs_data_create(); + if (limits.maxBitrateKbps > 0) + obs_data_set_int(obj, "maxBitrateKbps", limits.maxBitrateKbps); + if (limits.maxWidth > 0) + obs_data_set_int(obj, "maxWidth", limits.maxWidth); + if (limits.maxHeight > 0) + obs_data_set_int(obj, "maxHeight", limits.maxHeight); + if (limits.maxFpsNum > 0) { + obs_data_set_int(obj, "maxFpsNum", limits.maxFpsNum); + obs_data_set_int(obj, "maxFpsDen", limits.maxFpsDen > 0 ? limits.maxFpsDen : 1); + } + obs_data_set_obj(parent, "limits", obj); + obs_data_release(obj); } -void autoConfig::StartSetDefaultSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval) +static std::string serializeResult(const Session &session, const char *status, const std::vector &recommendations, + const std::string &errorCode = {}) { - asyncTests[ThreadedTests::SetDefaultSettings] = std::async(std::launch::async, SetDefaultSettings); + obs_data_t *root = obs_data_create(); + obs_data_set_int(root, "schemaVersion", kSchemaVersion); + obs_data_set_string(root, "sessionId", session.id.c_str()); + obs_data_set_string(root, "status", status); + if (!errorCode.empty()) { + obs_data_t *error = obs_data_create(); + obs_data_set_string(error, "code", errorCode.c_str()); + obs_data_set_obj(root, "error", error); + obs_data_release(error); + } + + obs_data_array_t *legs = obs_data_array_create(); + for (const auto &recommendation : recommendations) { + obs_data_t *leg = obs_data_create(); + obs_data_set_string(leg, "legId", recommendation.legId.c_str()); + obs_data_set_string(leg, "display", recommendation.display.c_str()); + + obs_data_array_t *destinations = obs_data_array_create(); + for (const auto &destination : recommendation.destinations) { + obs_data_t *item = obs_data_create(); + obs_data_set_string(item, "platform", destination.platform.c_str()); + obs_data_array_push_back(destinations, item); + obs_data_release(item); + } + obs_data_set_array(leg, "destinations", destinations); + obs_data_array_release(destinations); + + obs_data_t *measurement = obs_data_create(); + obs_data_set_string(measurement, "mode", recommendation.measurementMode.c_str()); + obs_data_set_string(measurement, "confidence", recommendation.confidence.c_str()); + if (!recommendation.reason.empty()) + obs_data_set_string(measurement, "reason", recommendation.reason.c_str()); + obs_data_set_obj(leg, "measurement", measurement); + obs_data_release(measurement); + + obs_data_t *value = obs_data_create(); + obs_data_set_int(value, "width", recommendation.value.width); + obs_data_set_int(value, "height", recommendation.value.height); + obs_data_set_int(value, "fpsNum", recommendation.value.fpsNum); + obs_data_set_int(value, "fpsDen", recommendation.value.fpsDen); + obs_data_set_int(value, "bitrateKbps", recommendation.value.bitrateKbps); + obs_data_set_string(value, "encoderId", recommendation.value.encoderId.c_str()); + obs_data_set_string(value, "codec", recommendation.value.codec.c_str()); + if (!recommendation.value.preset.empty()) + obs_data_set_string(value, "preset", recommendation.value.preset.c_str()); + obs_data_set_obj(leg, "recommendation", value); + obs_data_release(value); + + putLimits(leg, recommendation.limits); + obs_data_array_push_back(legs, leg); + obs_data_release(leg); + } + obs_data_set_array(root, "legs", legs); + obs_data_array_release(legs); - rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - AUTO_DEBUG; + std::string json = obs_data_get_json(root); + obs_data_release(root); + return json; } -int EvaluateBandwidth(ServerInfo &server, bool &connected, bool &stopped, bool &success, bool &errorOnStop, OBSData &service_settings, OBSService &service, - OBSOutput &output, OBSData &vencoder_settings) +struct ProbeResult { + bool success = false; + bool cancelled = false; + uint64_t measuredKbps = 0; + int platformCapKbps = 0; + std::string errorCode; +}; + +static bool silentAudioCallback(void *, uint64_t startTimestamp, uint64_t, uint64_t *outputTimestamp, uint32_t, struct audio_data_mixes_outputs *) { - connected = false; - stopped = false; - errorOnStop = false; + *outputTimestamp = startTimestamp; + return true; +} - obs_data_set_string(service_settings, "server", server.address.c_str()); - obs_service_update(service, service_settings); +class ScratchResources { +public: + explicit ScratchResources(Session &session_, int stopTimeoutMs_ = kProbeStopTimeoutMs) : session(session_), stopTimeoutMs(stopTimeoutMs_) {} + ~ScratchResources() { cleanup(); } + + Session &session; + int stopTimeoutMs; + uint32_t videoWidth = 0; + uint32_t videoHeight = 0; + uint32_t videoFpsNum = 0; + uint32_t videoFpsDen = 1; + video_t *syntheticVideo = nullptr; + obs_view_t *scratchView = nullptr; + obs_core_video_mix_t *scratchMix = nullptr; + std::unique_ptr scratchViewInfo; + bool coreVideoMix = false; + audio_t *syntheticAudio = nullptr; + obs_encoder_t *videoEncoder = nullptr; + obs_encoder_t *audioEncoder = nullptr; + obs_service_t *service = nullptr; + obs_output_t *output = nullptr; + std::atomic stopFeeder{false}; + std::atomic scheduledFrames{0}; + std::atomic submittedFrames{0}; + std::atomic lockFailedFrames{0}; + std::atomic lateFrames{0}; + std::thread feeder; + std::vector framePatternA; + std::vector framePatternB; + + bool createSyntheticVideo(uint32_t width, uint32_t height, uint32_t fpsNum, uint32_t fpsDen, bool useCoreVideoMix = false) + { + videoWidth = width; + videoHeight = height; + videoFpsNum = fpsNum; + videoFpsDen = fpsDen; + coreVideoMix = useCoreVideoMix; + if (coreVideoMix) { + // Hardware texture encoders require a real OBS video mix. Create an + // isolated, source-free view instead of attaching to any user canvas. + // obs_view_add2 creates only a private mix and does not reset the + // application's existing video contexts. + scratchViewInfo = std::make_unique(); + scratchViewInfo->base_width = width; + scratchViewInfo->base_height = height; + scratchViewInfo->output_width = width; + scratchViewInfo->output_height = height; + scratchViewInfo->fps_num = fpsNum; + scratchViewInfo->fps_den = fpsDen; + scratchViewInfo->fps_type = 1; + scratchViewInfo->output_format = VIDEO_FORMAT_NV12; + scratchViewInfo->colorspace = VIDEO_CS_709; + scratchViewInfo->range = VIDEO_RANGE_PARTIAL; + scratchViewInfo->scale_type = OBS_SCALE_BILINEAR; + scratchViewInfo->adapter = 0; + scratchViewInfo->gpu_conversion = true; + scratchView = obs_view_create(); + syntheticVideo = scratchView ? obs_view_add2(scratchView, scratchViewInfo.get()) : nullptr; + scratchMix = syntheticVideo ? obs_video_mix_get(scratchViewInfo.get(), OBS_MAIN_VIDEO_RENDERING) : nullptr; + if (!syntheticVideo || !scratchMix) { + if (scratchView) + obs_view_remove(scratchView); + if (scratchView) + obs_view_destroy(scratchView); + scratchView = nullptr; + scratchMix = nullptr; + scratchViewInfo.reset(); + return false; + } + return true; + } - if (!obs_output_start(output)) - return -1; + video_output_info info{}; + info.name = "auto_optimizer_synthetic_video"; + info.format = VIDEO_FORMAT_NV12; + info.fps_num = fpsNum; + info.fps_den = fpsDen; + info.width = width; + info.height = height; + info.cache_size = 3; + info.colorspace = VIDEO_CS_709; + info.range = VIDEO_RANGE_PARTIAL; + if (video_output_open(&syntheticVideo, &info) != VIDEO_OUTPUT_SUCCESS) + return false; - std::unique_lock ul(m); - if (cancel) { - ul.unlock(); - obs_output_force_stop(output); - return -1; - } - if (!stopped && !connected) - cv.wait(ul); - if (cancel) { - ul.unlock(); - obs_output_force_stop(output); - return -1; - } - if (!connected) { - return -1; + const size_t frameBytes = (size_t)width * (size_t)height * 3U / 2U; + framePatternA.resize(frameBytes); + framePatternB.resize(frameBytes); + uint64_t random = 0x9e3779b97f4a7c15ULL ^ ((uint64_t)width << 32) ^ ((uint64_t)height << 16) ^ fpsNum; + for (size_t offset = 0; offset < frameBytes; offset++) { + random ^= random << 7; + random ^= random >> 9; + random ^= random << 8; + framePatternA[offset] = (uint8_t)random; + framePatternB[offset] = (uint8_t)(random >> 8) ^ (uint8_t)(offset * 31U); + } + return true; } - uint64_t t_start = os_gettime_ns(); + bool createSyntheticAudio() + { + audio_output_info info{}; + info.name = "auto_optimizer_synthetic_audio"; + info.samples_per_sec = 48000; + info.format = AUDIO_FORMAT_FLOAT_PLANAR; + info.speakers = SPEAKERS_STEREO; + info.input_callback = silentAudioCallback; + return audio_output_open(&syntheticAudio, &info) == AUDIO_OUTPUT_SUCCESS; + } - //wait for start signal from output - cv.wait_for(ul, std::chrono::seconds(10)); - if (stopped) - return -1; - if (cancel) { - ul.unlock(); - obs_output_force_stop(output); - return -1; + void startFeeder() + { + if (coreVideoMix) + return; + feeder = std::thread([this]() { + const auto frameDuration = std::chrono::nanoseconds((1000000000ULL * videoFpsDen) / videoFpsNum); + auto nextFrame = std::chrono::steady_clock::now(); + uint64_t timestamp = os_gettime_ns(); + bool alternate = false; + while (!stopFeeder.load()) { + scheduledFrames.fetch_add(1, std::memory_order_relaxed); + video_frame frame{}; + if (video_output_lock_frame(syntheticVideo, &frame, 1, timestamp)) { + const std::vector &pattern = alternate ? framePatternB : framePatternA; + const uint8_t *luma = pattern.data(); + const uint8_t *chroma = pattern.data() + (size_t)videoWidth * videoHeight; + for (uint32_t y = 0; y < videoHeight; y++) + std::memcpy(frame.data[0] + y * frame.linesize[0], luma + (size_t)y * videoWidth, videoWidth); + for (uint32_t y = 0; y < videoHeight / 2; y++) + std::memcpy(frame.data[1] + y * frame.linesize[1], chroma + (size_t)y * videoWidth, videoWidth); + video_output_unlock_frame(syntheticVideo); + submittedFrames.fetch_add(1, std::memory_order_relaxed); + alternate = !alternate; + } else { + lockFailedFrames.fetch_add(1, std::memory_order_relaxed); + } + timestamp += (uint64_t)frameDuration.count(); + nextFrame += frameDuration; + const auto now = std::chrono::steady_clock::now(); + if (nextFrame < now) { + // Skip missed schedule slots instead of submitting a burst of + // catch-up frames that would distort encoder throughput. + lateFrames.fetch_add(1, std::memory_order_relaxed); + nextFrame = now + frameDuration; + timestamp = os_gettime_ns() + (uint64_t)frameDuration.count(); + } + std::this_thread::sleep_until(nextFrame); + } + }); } - obs_output_stop(output); + void publishOutput() + { + std::lock_guard lock(session.probeMutex); + session.activeProbeOutput = output; + } - while (!obs_output_active(output)) { - if (errorOnStop) { - ul.unlock(); + void cleanup() + { + if (output && obs_output_active(output)) { obs_output_force_stop(output); - return -1; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(stopTimeoutMs); + while (obs_output_active(output) && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + if (obs_output_active(output)) { + // Never release an active output or media objects it may still + // reference. The public cancel call has its own bounded wait and + // reports cleanup_timeout; this worker remains alive solely to + // finish safe teardown if OBS takes longer than expected. + while (obs_output_active(output)) { + obs_output_force_stop(output); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } + } + + { + std::lock_guard lock(session.probeMutex); + if (session.activeProbeOutput == output) + session.activeProbeOutput = nullptr; + if (output) { + obs_output_release(output); + output = nullptr; + } } + stopFeeder.store(true); + if (feeder.joinable()) + feeder.join(); - std::this_thread::sleep_for(std::chrono::milliseconds(500)); + if (videoEncoder) { + obs_encoder_release(videoEncoder); + videoEncoder = nullptr; + } + if (audioEncoder) { + obs_encoder_release(audioEncoder); + audioEncoder = nullptr; + } + if (syntheticVideo && !coreVideoMix) { + video_output_stop(syntheticVideo); + video_output_close(syntheticVideo); + syntheticVideo = nullptr; + } + if (scratchView) { + obs_view_remove(scratchView); + obs_view_destroy(scratchView); + scratchView = nullptr; + scratchMix = nullptr; + syntheticVideo = nullptr; + // The render thread removes orphaned mixes on its next tick. Keep + // the video-info snapshot alive until that tick has elapsed. + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + scratchViewInfo.reset(); + } + if (syntheticAudio) { + audio_output_close(syntheticAudio); + syntheticAudio = nullptr; + } + if (service) { + obs_service_release(service); + service = nullptr; + } } +}; - //wait for stop signal from output - cv.wait(ul); +static bool waitForOutputInactive(obs_output_t *output, int timeoutMs) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (output && obs_output_active(output) && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + return !output || !obs_output_active(output); +} - uint64_t total_time = os_gettime_ns() - t_start; - int total_bytes = (int)obs_output_get_total_bytes(output); - uint64_t bitrate = 0; +struct HardwareAttempt { + bool success = false; + bool cancelled = false; + bool timedOut = false; + uint32_t totalFrames = 0; + uint32_t skippedFrames = 0; + uint32_t encodedFrames = 0; + uint32_t scheduledFrames = 0; + uint32_t submittedFrames = 0; + uint32_t lockFailedFrames = 0; + uint32_t lateFrames = 0; + std::string errorCode; +}; - if (total_time > 0) { - bitrate = (uint64_t)total_bytes * 8U * 1000000000U / total_time / 1000U; +static bool waitForScratchInterval(const std::shared_ptr &session, obs_output_t *output, std::chrono::steady_clock::time_point intervalDeadline, + std::chrono::steady_clock::time_point phaseDeadline, HardwareAttempt &result) +{ + while (std::chrono::steady_clock::now() < intervalDeadline) { + if (session->cancelRequested.load()) { + result.cancelled = true; + if (output) + obs_output_force_stop(output); + return false; + } + if (std::chrono::steady_clock::now() >= phaseDeadline) { + result.timedOut = true; + if (output) + obs_output_force_stop(output); + return false; + } + if (output && !obs_output_active(output)) { + result.errorCode = "hardware_benchmark_output_stopped"; + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); } + return true; +} - runContext.startingBitrate = (int)obs_data_get_int(vencoder_settings, "bitrate"); - if (obs_output_get_frames_dropped(output) || (int)bitrate < (runContext.startingBitrate * 75 / 100)) { - server.bitrate = (int)bitrate * 70 / 100; - } else { - server.bitrate = runContext.startingBitrate; +static HardwareAttempt runEncoderWorkload(const std::shared_ptr &session, const CurrentSettings &candidate, + std::chrono::steady_clock::time_point phaseDeadline) +{ + HardwareAttempt result; + if (session->cancelRequested.load()) { + result.cancelled = true; + return result; + } + if (std::chrono::steady_clock::now() >= phaseDeadline) { + result.timedOut = true; + return result; + } + + const std::string resolvedEncoderId = resolveEncoderId(candidate.encoderId); + const bool useCoreVideoMix = resolvedEncoderId != ADVANCED_ENCODER_X264; + const std::string encoderId = useCoreVideoMix ? resolvedEncoderId : scratchEncoderId(candidate.encoderId); + if (encoderId.empty() || !obs_get_encoder_codec(encoderId.c_str())) { + result.errorCode = "hardware_benchmark_encoder_unavailable"; + return result; + } + + ScratchResources resources(*session, kHardwareStopTimeoutMs); + if (!resources.createSyntheticVideo((uint32_t)candidate.width, (uint32_t)candidate.height, (uint32_t)candidate.fpsNum, (uint32_t)candidate.fpsDen, + useCoreVideoMix)) { + result.errorCode = "hardware_benchmark_video_create_failed"; + return result; + } + if (!resources.createSyntheticAudio()) { + result.errorCode = "hardware_benchmark_audio_create_failed"; + return result; + } + + obs_data_t *encoderSettings = obs_data_create(); + obs_data_set_int(encoderSettings, "bitrate", std::clamp(candidate.bitrateKbps, 500, kProbeMaximumBitrateKbps)); + obs_data_set_string(encoderSettings, "rate_control", "CBR"); + obs_data_set_int(encoderSettings, "keyint_sec", 2); + if (encoderId == ADVANCED_ENCODER_X264 && isX264Preset(candidate.preset)) + obs_data_set_string(encoderSettings, "preset", candidate.preset.c_str()); + resources.videoEncoder = obs_video_encoder_create(encoderId.c_str(), "auto_optimizer_hardware_benchmark_encoder", encoderSettings, nullptr); + obs_data_release(encoderSettings); + if (!resources.videoEncoder) { + result.errorCode = "hardware_benchmark_encoder_create_failed"; + return result; + } + if (useCoreVideoMix) + obs_encoder_set_video_mix(resources.videoEncoder, resources.scratchMix); + else + obs_encoder_set_video(resources.videoEncoder, resources.syntheticVideo); + + obs_data_t *audioSettings = obs_data_create(); + obs_data_set_int(audioSettings, "bitrate", 32); + resources.audioEncoder = obs_audio_encoder_create("ffmpeg_aac", "auto_optimizer_hardware_benchmark_audio", audioSettings, 0, nullptr); + obs_data_release(audioSettings); + if (!resources.audioEncoder) { + result.errorCode = "hardware_benchmark_audio_encoder_create_failed"; + return result; + } + obs_encoder_set_audio(resources.audioEncoder, resources.syntheticAudio); + + resources.output = obs_output_create("null_output", "auto_optimizer_hardware_benchmark_output", nullptr, nullptr); + if (!resources.output) { + result.errorCode = "hardware_benchmark_output_create_failed"; + return result; + } + obs_output_set_video_encoder(resources.output, resources.videoEncoder); + obs_output_set_audio_encoder(resources.output, resources.audioEncoder, 0); + resources.publishOutput(); + resources.startFeeder(); + + if (session->cancelRequested.load()) { + result.cancelled = true; + return result; + } + if (!obs_output_start(resources.output)) { + result.errorCode = "hardware_benchmark_start_failed"; + return result; + } + + const auto warmupDeadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kHardwareWarmupMs); + if (!waitForScratchInterval(session, resources.output, warmupDeadline, phaseDeadline, result)) + return result; + + const uint32_t startTotal = video_output_get_total_frames(resources.syntheticVideo); + const uint32_t startSkipped = video_output_get_skipped_frames(resources.syntheticVideo); + const uint32_t startEncoded = obs_encoder_get_encoded_frames(resources.videoEncoder); + const uint32_t startScheduled = resources.scheduledFrames.load(std::memory_order_relaxed); + const uint32_t startSubmitted = resources.submittedFrames.load(std::memory_order_relaxed); + const uint32_t startLockFailed = resources.lockFailedFrames.load(std::memory_order_relaxed); + const uint32_t startLate = resources.lateFrames.load(std::memory_order_relaxed); + const auto sampleStart = std::chrono::steady_clock::now(); + const auto sampleDeadline = sampleStart + std::chrono::milliseconds(kHardwareSampleMs); + if (!waitForScratchInterval(session, resources.output, sampleDeadline, phaseDeadline, result)) + return result; + + const auto sampleEnd = std::chrono::steady_clock::now(); + result.totalFrames = video_output_get_total_frames(resources.syntheticVideo) - startTotal; + result.skippedFrames = video_output_get_skipped_frames(resources.syntheticVideo) - startSkipped; + result.encodedFrames = obs_encoder_get_encoded_frames(resources.videoEncoder) - startEncoded; + result.scheduledFrames = resources.scheduledFrames.load(std::memory_order_relaxed) - startScheduled; + result.submittedFrames = resources.submittedFrames.load(std::memory_order_relaxed) - startSubmitted; + result.lockFailedFrames = resources.lockFailedFrames.load(std::memory_order_relaxed) - startLockFailed; + result.lateFrames = resources.lateFrames.load(std::memory_order_relaxed) - startLate; + + obs_output_stop(resources.output); + if (!waitForOutputInactive(resources.output, kHardwareStopTimeoutMs)) { + obs_output_force_stop(resources.output); + if (!waitForOutputInactive(resources.output, kHardwareStopTimeoutMs)) { + result.errorCode = "hardware_benchmark_cleanup_timeout"; + return result; + } } - server.ms = obs_output_get_connect_time_ms(output); - success = true; - - //wait for deactivate signal from output - cv.wait(ul); - - return 0; + const double elapsedSeconds = std::chrono::duration(sampleEnd - sampleStart).count(); + const double requestedFps = (double)candidate.fpsNum / (double)candidate.fpsDen; + const uint32_t expectedFrames = (uint32_t)std::max(1.0, std::floor(requestedFps * elapsedSeconds)); + const uint32_t pipelineAllowance = std::min(4U, expectedFrames); + const uint32_t minimumEncoded = std::max(3U, (expectedFrames - pipelineAllowance) * 85U / 100U); + const uint32_t allowedSkipped = std::max(1U, result.totalFrames * 5U / 100U); + const uint32_t minimumSubmitted = std::max(3U, expectedFrames * 85U / 100U); + const uint32_t allowedLockFailed = std::max(1U, result.scheduledFrames * 5U / 100U); + const uint32_t allowedLate = std::max(1U, result.scheduledFrames * 5U / 100U); + const bool feederHealthy = useCoreVideoMix || (result.submittedFrames >= minimumSubmitted && result.lockFailedFrames <= allowedLockFailed && + result.lateFrames <= allowedLate); + result.success = feederHealthy && result.encodedFrames >= minimumEncoded && result.skippedFrames <= allowedSkipped; + if (!result.success) + result.errorCode = "hardware_benchmark_overloaded"; + return result; } -void sendErrorMessage(const std::string &message) +static bool sameHardwareWorkload(const CurrentSettings &left, const CurrentSettings &right) { - eventsMutex.lock(); - events.push(AutoConfigInfo("error", message, 0)); - eventsMutex.unlock(); + return left.width == right.width && left.height == right.height && left.fpsNum == right.fpsNum && left.fpsDen == right.fpsDen && + resolveEncoderId(left.encoderId) == resolveEncoderId(right.encoderId); } -int autoConfig::GetStartingBitrate(const std::string &serviceName) +static CurrentSettings lowerHardwareCandidate(CurrentSettings value, int longEdge, int shortEdge) { - OBSData service_settings = obs_data_create(); - obs_data_release(service_settings); - - obs_data_set_string(service_settings, "service", serviceName.c_str()); - - OBSService service = obs_service_create("rtmp_common", "temp_service", service_settings, nullptr); - obs_service_release(service); - - int bitrate = 10000; - - OBSData settings = obs_data_create(); - obs_data_release(settings); - obs_data_set_int(settings, "bitrate", bitrate); - obs_service_apply_encoder_settings(service, settings, nullptr); + const bool landscape = value.width >= value.height; + fitWithin(value, landscape ? longEdge : shortEdge, landscape ? shortEdge : longEdge); + capFps(value, 30, 1); + return value; +} - int startingBitrate = (int)obs_data_get_int(settings, "bitrate"); - return startingBitrate; +static bool isInfrastructureFailure(const HardwareAttempt &attempt) +{ + return !attempt.success && !attempt.cancelled && attempt.errorCode != "hardware_benchmark_overloaded"; } -void autoConfig::TestBandwidthThreadV2(void) +static HardwareAssessment assessHardware(const std::shared_ptr &session, const LegRequest &leg, std::chrono::steady_clock::time_point phaseDeadline) { - bool gotError = false; - std::vector testResults; + HardwareAssessment assessment; + assessment.attempted = true; + const EncoderSelection initialSelection = chooseEncoder(leg.current); + CurrentSettings target = baseRecommendation(leg); + + const bool landscape = target.width >= target.height; + bool ceilingConstrained = fitWithin(target, landscape ? kHardwareMaximumLongEdge : kHardwareMaximumShortEdge, + landscape ? kHardwareMaximumShortEdge : kHardwareMaximumLongEdge); + ceilingConstrained = capFps(target, 60, 1) || ceilingConstrained; + if (target.encoderId.empty()) { + assessment.constrained = true; + assessment.reason = "hardware_no_usable_encoder"; + assessment.value = target; + return assessment; + } + + auto unavailable = [&](const HardwareAttempt &failedAttempt) { + assessment.passed = false; + assessment.constrained = true; + assessment.reason = failedAttempt.timedOut || std::chrono::steady_clock::now() >= phaseDeadline ? "hardware_benchmark_timeout" + : "hardware_benchmark_unavailable"; + // An infrastructure failure provides no evidence for a downgrade. Keep + // the capped current recommendation instead of returning an untested + // resolution or encoder. + assessment.value = target; + }; - { - std::lock_guard lock(eventsMutex); - events.push(AutoConfigInfo("starting_step", "bandwidth_test", 0)); + HardwareAttempt attempt = runEncoderWorkload(session, target, phaseDeadline); + if (attempt.cancelled) { + assessment.cancelled = true; + return assessment; + } + if (attempt.success) { + assessment.passed = true; + assessment.value = target; + assessment.constrained = initialSelection.replaced || ceilingConstrained; + if (initialSelection.replaced) + assessment.reason = "hardware_encoder_unavailable_fallback"; + else if (ceilingConstrained) + assessment.reason = "hardware_benchmark_ceiling"; + return assessment; + } + if (isInfrastructureFailure(attempt)) { + unavailable(attempt); + return assessment; + } + + // A genuine overload is the only reason to test lower settings. Preserve the + // selected hardware encoder through the first downgrade before considering + // a software fallback. + CurrentSettings lowerSelected = lowerHardwareCandidate(target, 1280, 720); + if (!sameHardwareWorkload(target, lowerSelected) && std::chrono::steady_clock::now() < phaseDeadline) { + attempt = runEncoderWorkload(session, lowerSelected, phaseDeadline); + if (attempt.cancelled) { + assessment.cancelled = true; + return assessment; + } + if (attempt.success) { + assessment.passed = true; + assessment.constrained = true; + assessment.reason = "hardware_benchmark_resolution_fallback"; + assessment.value = lowerSelected; + return assessment; + } + if (isInfrastructureFailure(attempt)) { + unavailable(attempt); + return assessment; + } } - // Resolve the streaming targets the frontend passed to InitializeAutoConfig. - // Skip ids that no longer resolve (object was destroyed between Initialize - // and the bandwidth test) or that have no service set. - std::vector targets; - std::vector targetIds; - - for (uint64_t uid : runContext.targetStreamingIds) { - osn::Streaming *s = osn::IStreaming::Manager::GetInstance().find(uid); - if (s && s->service) { - targets.push_back(s); - targetIds.push_back(uid); + CurrentSettings softwareCandidate = lowerSelected; + if (osn::EncoderUtils::isEncoderRegistered(ADVANCED_ENCODER_X264) && resolveEncoderId(softwareCandidate.encoderId) != ADVANCED_ENCODER_X264) { + applyEncoderSelection(softwareCandidate, {ADVANCED_ENCODER_X264, true}); + attempt = runEncoderWorkload(session, softwareCandidate, phaseDeadline); + if (attempt.cancelled) { + assessment.cancelled = true; + return assessment; + } + if (attempt.success) { + assessment.passed = true; + assessment.constrained = true; + assessment.reason = "hardware_benchmark_encoder_fallback"; + assessment.value = softwareCandidate; + return assessment; + } + if (isInfrastructureFailure(attempt)) { + unavailable(attempt); + return assessment; } } - if (targets.empty()) { - sendErrorMessage("no_streaming_targets_provided"); - gotError = true; - } - - if (!gotError) { - std::vector testingServices; - std::vector testingServiceTargetIdx; - - for (size_t i = 0; i < targets.size(); i++) { - const char *type = osn::streaming_helpers::getStreamOutputType(targets[i]->service); - if (!type) - type = "rtmp_output"; - std::string outputName = "autoconfig_bw_" + std::to_string(i); - targets[i]->CreateOutput(type, outputName); - - // Pick a high test bitrate so the measurement reflects the link's - // real ceiling rather than whatever low value the user has set. - // - user's current bitrate (might already be high) - // - platform cap probed via obs_service_apply_encoder_settings - // (Twitch returns 6000 for non-partners; partners higher) - // - 6000 fallback when no platform hook exists (custom RTMP, etc.) - int userBitrate = 0; - if (targets[i]->videoEncoder) { - obs_data_t *s = obs_encoder_get_settings(targets[i]->videoEncoder); - userBitrate = (int)obs_data_get_int(s, "bitrate"); - obs_data_release(s); - } - int platformCap = 0; - if (targets[i]->service) { - obs_data_t *probe = obs_data_create(); - obs_data_set_int(probe, "bitrate", 50000); - obs_service_apply_encoder_settings(targets[i]->service, probe, nullptr); - int capped = (int)obs_data_get_int(probe, "bitrate"); - if (capped > 0 && capped < 50000) - platformCap = capped; - obs_data_release(probe); - } - int testBitrate = std::max({userBitrate, platformCap, 6000}); - blog(LOG_INFO, "TestBandwidthV2: target %zu test bitrate %d (user=%d, platformCap=%d)", i, testBitrate, userBitrate, platformCap); - - // Pre-record the test setup; measurement fields filled below. - AutoconfigRun::BandwidthDetail bd; - bd.targetId = targetIds[i]; - bd.testBitrate = testBitrate; - bd.platformCapProbed = platformCap; - runContext.bandwidthDetails.push_back(bd); - - targets[i]->testBandwidth(gotError, testBitrate); - - if (!gotError && targets[i]->GetOutput()) { - testingServices.push_back(targets[i]); - testingServiceTargetIdx.push_back(i); - } else if (targets[i]->GetOutput() && obs_output_active(targets[i]->GetOutput())) { - obs_output_stop(targets[i]->GetOutput()); - } + CurrentSettings conservative = lowerHardwareCandidate(softwareCandidate, 640, 360); + if (!sameHardwareWorkload(softwareCandidate, conservative) && std::chrono::steady_clock::now() < phaseDeadline) { + attempt = runEncoderWorkload(session, conservative, phaseDeadline); + if (attempt.cancelled) { + assessment.cancelled = true; + return assessment; } - - if (!gotError && !testingServices.empty()) { - auto startTime = std::chrono::steady_clock::now(); - bool allConnected = false; - - // Wait up to 10 seconds for all services to connect or fail. - while (!allConnected && !gotError && std::chrono::steady_clock::now() - startTime < std::chrono::seconds(10)) { - allConnected = true; - - for (auto *streaming : testingServices) { - // Surface a connection failure immediately; drained signals are - // only supplemental to the real output state checked below. - if (streaming->testQuery() == "error") { - gotError = true; - break; - } - - // Primary readiness check: a target counts as connected only once - // libobs reports the output active. Keying off drained signals alone - // would treat a target that hasn't emitted one yet as ready, exit - // the wait early, and measure totalBytes == 0. - obs_output_t *output = streaming->GetOutput(); - if (!output || !obs_output_active(output)) - allConnected = false; - } - - std::unique_lock ul(m); - if (cancel) { - gotError = true; - break; - } - ul.unlock(); - - if (!allConnected && !gotError) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - } - - // Let the outputs stream for a few seconds so data accumulates - // before we sample obs_output_get_total_bytes(). Without this - // the loop above exits as soon as signals are drained (often - // < 100 ms after the RTMP connection opens) and totalBytes is 0. - int dataWaitMs = 0; - autoConfig::ResourceSampler sampler; - if (!gotError && allConnected) { - const int targetWaitMs = 5000; - auto dataStart = std::chrono::steady_clock::now(); - sampler.start("bandwidth"); - while (std::chrono::steady_clock::now() - dataStart < std::chrono::milliseconds(targetWaitMs)) { - std::unique_lock ul(m); - if (cancel) { - gotError = true; - break; - } - ul.unlock(); - std::this_thread::sleep_for(std::chrono::milliseconds(250)); - sampler.sample(); - } - dataWaitMs = (int)std::chrono::duration_cast(std::chrono::steady_clock::now() - dataStart).count(); - recordResourceWindow(sampler.stop()); - } - - if (!gotError) { - for (size_t si = 0; si < testingServices.size(); si++) { - auto *streaming = testingServices[si]; - if (streaming->GetOutput() && obs_output_active(streaming->GetOutput())) { - uint64_t totalBytes = obs_output_get_total_bytes(streaming->GetOutput()); - int connectTimeMs = obs_output_get_connect_time_ms(streaming->GetOutput()); - int droppedFrames = obs_output_get_frames_dropped(streaming->GetOutput()); - int totalFrames = obs_output_get_total_frames(streaming->GetOutput()); - - obs_output_stop(streaming->GetOutput()); - - if (totalBytes > 0) { - // connectTimeMs may be 0 for localhost; fall back - // to the data-wait duration for bitrate estimation. - int elapsedMs = connectTimeMs > 0 ? connectTimeMs : std::max(dataWaitMs, 1); - uint64_t bitrate = (totalBytes * 8ULL * 1000ULL) / static_cast(elapsedMs) / 1000ULL; - - std::string serverAddress; - if (streaming->service) { - serverAddress = - obs_service_get_connect_info(streaming->service, OBS_SERVICE_CONNECT_INFO_SERVER_URL); - } - - ServerInfo result; - result.address = serverAddress; - result.ms = elapsedMs; - result.targetIndex = testingServiceTargetIdx[si]; - - // Use the per-target test bitrate (still active on the encoder - // until CleanTestMode runs below) as the reference, not the - // global runContext.startingBitrate which doesn't reflect the - // per-target ceiling-search override. - int testBitrateRef = 0; - if (streaming->videoEncoder) { - obs_data_t *encSettings = obs_encoder_get_settings(streaming->videoEncoder); - testBitrateRef = (int)obs_data_get_int(encSettings, "bitrate"); - obs_data_release(encSettings); - } - if (testBitrateRef <= 0) - testBitrateRef = runContext.startingBitrate; - - if (droppedFrames > 0 || (int)bitrate < (testBitrateRef * 75 / 100)) { - result.bitrate = (int)bitrate * 70 / 100; - } else { - result.bitrate = testBitrateRef; - } - - testResults.push_back(result); - - // Fill the measurement side of the per-target detail record - // (setup side was filled before testBandwidth) and emit the - // bandwidth_result event with a JSON payload for the new UI. - size_t targetIdx = testingServiceTargetIdx[si]; - if (targetIdx < runContext.bandwidthDetails.size()) { - auto &bd = runContext.bandwidthDetails[targetIdx]; - bd.measuredKbps = bitrate; - bd.droppedFrames = droppedFrames; - bd.totalFrames = totalFrames; - bd.totalBytes = totalBytes; - bd.elapsedMs = elapsedMs; - bd.serverTested = serverAddress; - - obs_data_t *p = obs_data_create(); - obs_data_set_int(p, "targetId", (long long)bd.targetId); - obs_data_set_int(p, "testBitrate", bd.testBitrate); - obs_data_set_int(p, "platformCapProbed", bd.platformCapProbed); - obs_data_set_int(p, "measuredKbps", (long long)bd.measuredKbps); - obs_data_set_int(p, "droppedFrames", bd.droppedFrames); - obs_data_set_int(p, "totalFrames", bd.totalFrames); - obs_data_set_int(p, "totalBytes", (long long)bd.totalBytes); - obs_data_set_int(p, "elapsedMs", bd.elapsedMs); - obs_data_set_string(p, "serverTested", bd.serverTested.c_str()); - std::string payload = obs_data_get_json(p); - obs_data_release(p); - - std::lock_guard lock(eventsMutex); - events.push(AutoConfigInfo("bandwidth_result", "target_" + std::to_string(bd.targetId), 100, - payload)); - } - } - } - } - } + if (attempt.success) { + assessment.passed = true; + assessment.constrained = true; + assessment.reason = "hardware_benchmark_resolution_fallback"; + assessment.value = conservative; + return assessment; } - - for (auto *streaming : targets) { - streaming->CleanTestMode(); + if (isInfrastructureFailure(attempt)) { + unavailable(attempt); + return assessment; } } - if (!gotError) { - if (testResults.empty()) { - sendErrorMessage("no_valid_bandwidth_results"); - gotError = true; - } else { - // Build per-target results. Each target picks its best server - // (highest bitrate, lowest latency). - runContext.targetResults.clear(); - for (size_t ti = 0; ti < targetIds.size(); ti++) { - std::vector targetSpecific; - for (auto &r : testResults) { - if (r.targetIndex == ti) - targetSpecific.push_back(r); - } - if (targetSpecific.empty()) - continue; - - std::sort(targetSpecific.begin(), targetSpecific.end(), [](const ServerInfo &a, const ServerInfo &b) { - return (a.bitrate > b.bitrate) || (a.bitrate == b.bitrate && a.ms < b.ms); - }); - - AutoconfigRun::TargetResult tr; - tr.streamingId = targetIds[ti]; - tr.idealBitrate = targetSpecific.front().bitrate; - tr.server = targetSpecific.front().address; - runContext.targetResults.push_back(tr); - } + // No candidate passed. Do not present the last failed candidate as a safe + // recommendation; retain capped current settings and make the low-confidence + // outcome explicit to Desktop. + assessment.passed = false; + assessment.constrained = true; + assessment.reason = std::chrono::steady_clock::now() >= phaseDeadline || attempt.timedOut ? "hardware_benchmark_timeout" + : "hardware_benchmark_overloaded"; + assessment.value = target; + return assessment; +} - // Global idealBitrate = minimum across all targets (conservative - // for shared canvas resolution/FPS selection). - if (!runContext.targetResults.empty()) { - uint64_t minBitrate = UINT64_MAX; - for (auto &tr : runContext.targetResults) { - if (tr.idealBitrate < minBitrate) - minBitrate = tr.idealBitrate; - } - runContext.idealBitrate = minBitrate; - runContext.server = runContext.targetResults[0].server; - } +static ProbeResult runTwitchProbe(const std::shared_ptr &session, const LegRequest &leg) +{ + ProbeResult result; + ScratchResources resources(*session); + + obs_data_t *serviceSettings = obs_data_create(); + obs_data_set_string(serviceSettings, "service", "Twitch"); + obs_data_set_string(serviceSettings, "server", session->probe.server.c_str()); + const std::string bandwidthKey = normalizeTwitchBandwidthKey(session->probe.streamKey); + obs_data_set_string(serviceSettings, "key", bandwidthKey.c_str()); + resources.service = obs_service_create_private("rtmp_common", "auto_optimizer_twitch_probe_service", serviceSettings); + obs_data_release(serviceSettings); + + // Drop the only application-owned copy as soon as the disposable service has + // consumed it. It is never included in events, result JSON, or logs. + session->probe.streamKey.clear(); + if (!resources.service) { + result.errorCode = "twitch_probe_service_create_failed"; + return result; + } + + obs_data_t *encoderSettings = obs_data_create(); + const int requested = std::clamp(std::max(leg.current.bitrateKbps, 6000), 500, kProbeMaximumBitrateKbps); + obs_data_set_int(encoderSettings, "bitrate", requested); + obs_data_set_string(encoderSettings, "rate_control", "CBR"); + obs_data_set_string(encoderSettings, "preset", "veryfast"); + obs_data_set_int(encoderSettings, "keyint_sec", 2); + + obs_data_t *platformProbe = obs_data_create(); + obs_data_set_int(platformProbe, "bitrate", kProbeMaximumBitrateKbps); + obs_service_apply_encoder_settings(resources.service, platformProbe, nullptr); + const int platformReturned = (int)obs_data_get_int(platformProbe, "bitrate"); + if (platformReturned > 0 && platformReturned < kProbeMaximumBitrateKbps) + result.platformCapKbps = platformReturned; + obs_data_release(platformProbe); + if (result.platformCapKbps > 0) + obs_data_set_int(encoderSettings, "bitrate", std::min(requested, result.platformCapKbps)); + obs_service_apply_encoder_settings(resources.service, encoderSettings, nullptr); + + if (!resources.createSyntheticVideo(128, 128, 30, 1)) { + obs_data_release(encoderSettings); + result.errorCode = "twitch_probe_video_create_failed"; + return result; + } + if (!resources.createSyntheticAudio()) { + obs_data_release(encoderSettings); + result.errorCode = "twitch_probe_audio_create_failed"; + return result; + } + + resources.videoEncoder = obs_video_encoder_create(ADVANCED_ENCODER_X264, "auto_optimizer_twitch_probe_encoder", encoderSettings, nullptr); + obs_data_release(encoderSettings); + if (!resources.videoEncoder) { + result.errorCode = "twitch_probe_encoder_create_failed"; + return result; + } + + obs_encoder_set_video(resources.videoEncoder, resources.syntheticVideo); + obs_data_t *audioEncoderSettings = obs_data_create(); + obs_data_set_int(audioEncoderSettings, "bitrate", 32); + resources.audioEncoder = obs_audio_encoder_create("ffmpeg_aac", "auto_optimizer_twitch_probe_audio_encoder", audioEncoderSettings, 0, nullptr); + obs_data_release(audioEncoderSettings); + if (!resources.audioEncoder) { + result.errorCode = "twitch_probe_audio_encoder_create_failed"; + return result; + } + obs_encoder_set_audio(resources.audioEncoder, resources.syntheticAudio); + resources.startFeeder(); + + resources.output = obs_output_create("rtmp_output", "auto_optimizer_twitch_probe_output", nullptr, nullptr); + if (!resources.output) { + result.errorCode = "twitch_probe_output_create_failed"; + return result; + } + obs_output_set_reconnect_settings(resources.output, 0, 0); + obs_output_set_video_encoder(resources.output, resources.videoEncoder); + obs_output_set_audio_encoder(resources.output, resources.audioEncoder, 0); + obs_output_set_service(resources.output, resources.service); + resources.publishOutput(); + + if (session->cancelRequested.load()) { + result.cancelled = true; + return result; + } + if (!obs_output_start(resources.output)) { + result.errorCode = "twitch_probe_start_failed"; + return result; + } + + const auto connectDeadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kProbeConnectTimeoutMs); + while (!obs_output_active(resources.output) && std::chrono::steady_clock::now() < connectDeadline) { + if (session->cancelRequested.load()) { + result.cancelled = true; + obs_output_force_stop(resources.output); + return result; } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); } - - { - std::lock_guard lock(eventsMutex); - events.push(AutoConfigInfo("stopping_step", "bandwidth_test", 100)); + if (!obs_output_active(resources.output)) { + result.errorCode = "twitch_probe_connect_failed"; + return result; } -} -// Old implementation - commented out as it has compilation errors -// This will be removed after V2 development is finished -#ifdef OLD_BANDWIDTH_TEST //deprecated -void autoConfig::TestBandwidthThread(void) -{ - eventsMutex.lock(); - events.push(AutoConfigInfo("starting_step", "bandwidth_test", 0)); - eventsMutex.unlock(); - - bool connected = false; - bool stopped = false; - bool errorOnStop = false; - bool gotError = false; - - const char *serverType = "rtmp_common"; - - OBSEncoder vencoder = obs_video_encoder_create(ADVANCED_ENCODER_X264, "test_x264", nullptr, nullptr); - OBSEncoder aencoder = obs_audio_encoder_create("ffmpeg_aac", "test_aac", nullptr, 0, nullptr); - OBSOutput output = obs_output_create("rtmp_output", "test_stream", nullptr, nullptr); - - /* -----------------------------------*/ - /* configure settings */ - - // service: "service", "server", "key" - // vencoder: "bitrate", "rate_control", - // obs_service_apply_encoder_settings - // aencoder: "bitrate" - // output: "bind_ip" via main config -> "Output", "BindIP" - // obs_output_set_service - - OBSData vencoder_settings = obs_data_create(); - OBSData aencoder_settings = obs_data_create(); - OBSData output_settings = obs_data_create(); - obs_data_release(vencoder_settings); - obs_data_release(aencoder_settings); - obs_data_release(output_settings); - - osn::Service::Manager::GetInstance().for_each([&gotError](obs_service_t *service) { - Service serviceType = Service::Other; - std::string key; - std::string keyToEvaluate; - std::string serviceName; - OBSData service_settings = obs_data_create(); - obs_data_release(service_settings); + const auto warmupDeadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kProbeWarmupMs); + while (std::chrono::steady_clock::now() < warmupDeadline) { + if (session->cancelRequested.load()) { + result.cancelled = true; + obs_output_force_stop(resources.output); + return result; + } + if (!obs_output_active(resources.output)) { + result.errorCode = "twitch_probe_disconnected"; + return result; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } - if (service) { - obs_service_t *currentService = service; - if (currentService) { - obs_data_t *currentServiceSettings = obs_service_get_settings(currentService); - if (currentServiceSettings) { - serviceName = obs_data_get_string(currentServiceSettings, "service"); - - key = obs_service_get_connect_info(currentService, OBS_SERVICE_CONNECT_INFO_STREAM_KEY); - if (key.empty()) { - sendErrorMessage("invalid_stream_settings"); - gotError = true; - } - } else { - sendErrorMessage("invalid_stream_settings"); - gotError = true; - } - } else { - sendErrorMessage("invalid_stream_settings"); - gotError = true; - } - if (gotError) { - return; - } - - if (serviceName == "Twitch") - serviceType = Service::Twitch; - else if (serviceName == "hitbox.tv") - serviceType = Service::Hitbox; - else if (serviceName == "beam.pro") - serviceType = Service::Beam; - else if (serviceName.find("YouTube") != std::string::npos) - serviceType = Service::YouTube; - else - serviceType = Service::Other; - - keyToEvaluate = key; - - if (serviceType == Service::Twitch) { - string_depad_key(key); - keyToEvaluate += "?bandwidthtest"; - } - - // todo - will it work without making it custom server? - // if (serviceType == Service::YouTube) { - // serverName = "Stream URL"; - // server = obs_service_get_connect_info(currentService, OBS_SERVICE_CONNECT_INFO_SERVER_URL); - // } - - obs_data_set_string(service_settings, "service", serviceName.c_str()); - obs_data_set_string(service_settings, "key", keyToEvaluate.c_str()); - - int awstartingBitrate = GetStartingBitrate(serviceName); + const uint64_t startBytes = obs_output_get_total_bytes(resources.output); + const auto sampleStart = std::chrono::steady_clock::now(); + const auto sampleDeadline = sampleStart + std::chrono::milliseconds(kProbeSampleMs); + while (std::chrono::steady_clock::now() < sampleDeadline) { + if (session->cancelRequested.load()) { + result.cancelled = true; + obs_output_force_stop(resources.output); + return result; } - }); - - if (gotError) { - obs_output_release(output); - obs_encoder_release(vencoder); - obs_encoder_release(aencoder); - return; - } - - // if (!customServer) { - // if (serviceName == "Twitch") - // serviceSelected = Service::Twitch; - // else if (serviceName == "hitbox.tv") - // serviceSelected = Service::Hitbox; - // else if (serviceName == "beam.pro") - // serviceSelected = Service::Beam; - // else if (serviceName.find("YouTube") != std::string::npos) - // serviceSelected = Service::YouTube; - // else - // serviceSelected = Service::Other; - // } else { - // serviceSelected = Service::Other; - // } - //std::string keyToEvaluate = key; - - // if (serviceSelected == Service::Twitch) { - // string_depad_key(key); - // keyToEvaluate += "?bandwidthtest"; - // } - - // if (serviceSelected == Service::YouTube) { - // serverName = "Stream URL"; - // server = obs_service_get_connect_info(currentService, OBS_SERVICE_CONNECT_INFO_SERVER_URL); - // } - - // obs_data_set_string(service_settings, "service", serviceName.c_str()); - // obs_data_set_string(service_settings, "key", keyToEvaluate.c_str()); - - //Setting starting bitrate - // OBSData service_settingsawd = obs_data_create(); - // obs_data_release(service_settingsawd); - - // obs_data_set_string(service_settingsawd, "service", serviceName.c_str()); - - // OBSService servicewad = obs_service_create(serverType, "temp_service", service_settingsawd, nullptr); - // obs_service_release(servicewad); - - // int bitrate = 10000; - - // OBSData settings = obs_data_create(); - // obs_data_release(settings); - // obs_data_set_int(settings, "bitrate", bitrate); - // obs_service_apply_encoder_settings(servicewad, settings, nullptr); - - // int awstartingBitrate = (int)obs_data_get_int(settings, "bitrate"); - - obs_data_set_int(vencoder_settings, "bitrate", awstartingBitrate); - obs_data_set_string(vencoder_settings, "rate_control", "CBR"); - obs_data_set_string(vencoder_settings, "preset", "veryfast"); - obs_data_set_int(vencoder_settings, "keyint_sec", 2); - - obs_data_set_int(aencoder_settings, "bitrate", 32); - - //todo get bind if from new api - const char *bind_ip = config_get_string(ConfigManager::getInstance().getBasic(), "Output", "BindIP"); - obs_data_set_string(output_settings, "bind_ip", bind_ip); - - /* -----------------------------------*/ - /* determine which servers to test */ - - // std::vector servers; - // if (customServer) - // servers.emplace_back(server.c_str(), server.c_str()); - // else - // GetServers(servers); - - /* just use the first server if it only has one alternate server */ - // if (servers.size() < 3) - // servers.resize(1); - - /* -----------------------------------*/ - /* apply settings */ - - obs_service_update(service, service_settings); - obs_service_apply_encoder_settings(service, vencoder_settings, aencoder_settings); - - obs_encoder_update(vencoder, vencoder_settings); - obs_encoder_update(aencoder, aencoder_settings); - - obs_encoder_set_video_mix(vencoder, obs_video_mix_get(ovi, OBS_MAIN_VIDEO_RENDERING)); - obs_encoder_set_audio(aencoder, obs_get_audio()); - - /* -----------------------------------*/ - /* connect encoders/services/outputs */ - - obs_output_set_video_encoder(output, vencoder); - obs_output_set_audio_encoder(output, aencoder, 0); - - obs_output_update(output, output_settings); - - obs_output_set_service(output, service); - - /* -----------------------------------*/ - /* connect signals */ - - auto on_started = [&]() { - std::unique_lock lock(m); - connected = true; - stopped = false; - cv.notify_one(); - }; - - auto on_stopped = [&]() { - const char *output_error = obs_output_get_last_error(output); - - if (output_error == nullptr) { - std::unique_lock lock(m); - connected = false; - stopped = true; - cv.notify_one(); - } else { - errorOnStop = true; + if (!obs_output_active(resources.output)) { + result.errorCode = "twitch_probe_disconnected"; + return result; } - }; - - auto on_deactivate = [&]() { cv.notify_one(); }; - - using on_started_t = decltype(on_started); - using on_stopped_t = decltype(on_stopped); - using on_deactivate_t = decltype(on_deactivate); - - auto pre_on_started = [](void *data, calldata_t *) { - on_started_t &on_started = *reinterpret_cast(data); - on_started(); - }; - - auto pre_on_stopped = [](void *data, calldata_t *) { - on_stopped_t &on_stopped = *reinterpret_cast(data); - on_stopped(); - }; - - auto pre_on_deactivate = [](void *data, calldata_t *) { - on_deactivate_t &on_deactivate = *reinterpret_cast(data); - on_deactivate(); - }; - - signal_handler *sh = obs_output_get_signal_handler(output); - signal_handler_connect(sh, "start", pre_on_started, &on_started); - signal_handler_connect(sh, "stop", pre_on_stopped, &on_stopped); - signal_handler_connect(sh, "deactivate", pre_on_deactivate, &on_deactivate); - - /* -----------------------------------*/ - /* test servers */ - - int bestBitrate = 0; - int bestMS = 0x7FFFFFFF; - std::string bestServer; - std::string bestServerName; - bool success = false; - - if (serverName.compare("") != 0) { - ServerInfo info(serverName.c_str(), server.c_str()); + if (obs_output_get_total_bytes(resources.output) - startBytes >= kProbeMaxBytes) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } - if (EvaluateBandwidth(info, connected, stopped, success, errorOnStop, service_settings, service, output, vencoder_settings) < 0) { - eventsMutex.lock(); - events.push(AutoConfigInfo("error", "invalid_stream_settings", 0)); - eventsMutex.unlock(); - gotError = true; - } else { - bestServer = info.address; - bestServerName = info.name; - bestBitrate = info.bitrate; + const auto sampleEnd = std::chrono::steady_clock::now(); + const uint64_t endBytes = obs_output_get_total_bytes(resources.output); + const uint64_t elapsedNs = (uint64_t)std::chrono::duration_cast(sampleEnd - sampleStart).count(); + if (endBytes <= startBytes || elapsedNs == 0) { + result.errorCode = "twitch_probe_no_data"; + return result; + } - eventsMutex.lock(); - events.push(AutoConfigInfo("progress", "bandwidth_test", 100)); - eventsMutex.unlock(); + // Measure only the sample window. Connection/handshake latency is a separate + // diagnostic and must never be used as the throughput denominator. + result.measuredKbps = ((endBytes - startBytes) * 8ULL * 1000000000ULL) / elapsedNs / 1000ULL; + obs_output_stop(resources.output); + if (!waitForOutputInactive(resources.output, kProbeStopTimeoutMs)) { + obs_output_force_stop(resources.output); + if (!waitForOutputInactive(resources.output, kProbeStopTimeoutMs)) { + result.errorCode = "twitch_probe_cleanup_timeout"; + return result; } - // } else { - // for (size_t i = 0; i < servers.size(); i++) { - // EvaluateBandwidth(servers[i], connected, stopped, success, errorOnStop, service_settings, service, output, vencoder_settings); - // eventsMutex.lock(); - // events.push(AutoConfigInfo("progress", "bandwidth_test", (double)(i + 1) * 100 / servers.size())); - // eventsMutex.unlock(); - // } - } - - if (!success && !gotError) { - eventsMutex.lock(); - events.push(AutoConfigInfo("error", "invalid_stream_settings", 0)); - eventsMutex.unlock(); - gotError = true; - } - - if (!gotError) { - // for (auto &server : servers) { - // bool close = abs(server.bitrate - bestBitrate) < 400; - - // if ((!close && server.bitrate > bestBitrate) || (close && server.ms < bestMS)) { - // bestServer = server.address; - // bestServerName = server.name; - // bestBitrate = server.bitrate; - // bestMS = server.ms; - // } - // } - runContext.server = bestServer; - serverName = bestServerName; - runContext.idealBitrate = bestBitrate; - } - - obs_output_release(output); - obs_encoder_release(vencoder); - obs_encoder_release(aencoder); - - if (!gotError) { - eventsMutex.lock(); - events.push(AutoConfigInfo("stopping_step", "bandwidth_test", 100)); - eventsMutex.unlock(); } -} -#endif //deprecated -/* this is used to estimate the lower bitrate limit for a given - * resolution/fps. yes, it is a totally arbitrary equation that gets - * the closest to the expected values */ -static long double EstimateBitrateVal(int cx, int cy, int fps_num, int fps_den) -{ - long fps = long((long double)fps_num / (long double)fps_den); - long double areaVal = pow((long double)(cx * cy), 0.85l); - return areaVal * sqrt(pow(fps, 1.1l)); + result.success = result.measuredKbps > 0; + return result; } -static long double EstimateMinBitrate(int cx, int cy, int fps_num, int fps_den) +static void completeCancelled(const std::shared_ptr &session) { - long double val = EstimateBitrateVal((int)runContext.baseResolutionCX, (int)runContext.baseResolutionCY, 60, 1) / 5800.0l; - if (val < std::numeric_limits::epsilon() && val > -std::numeric_limits::epsilon()) { - return 0.0; + session->probe.streamKey.clear(); + { + std::lock_guard lock(session->mutex); + session->resultJson = serializeResult(*session, "cancelled", {}, "cancelled"); } - - return EstimateBitrateVal(cx, cy, fps_num, fps_den) / val; + session->state.store(SessionState::Cancelled); + pushEvent(session, "cancelled", "cleanup", 100, "cancelled"); } -static long double EstimateUpperBitrate(int cx, int cy, int fps_num, int fps_den) +static void completeFailed(const std::shared_ptr &session, const char *code) { - long double val = EstimateBitrateVal(1280, 720, 30, 1) / 3000.0l; - if (val < std::numeric_limits::epsilon() && val > -std::numeric_limits::epsilon()) { - return 0.0; + session->probe.streamKey.clear(); + { + std::lock_guard lock(session->mutex); + session->resultJson = serializeResult(*session, "failed", {}, code); } - - return EstimateBitrateVal(cx, cy, fps_num, fps_den) / val; + session->state.store(SessionState::Failed); + pushEvent(session, "error", "cleanup", 100, code); + pushEvent(session, "complete", "cleanup", 100, code); } -struct Result { - int cx; - int cy; - int fps_num; - int fps_den; - - inline Result(int cx_, int cy_, int fps_num_, int fps_den_) : cx(cx_), cy(cy_), fps_num(fps_num_), fps_den(fps_den_) {} -}; - -void autoConfig::FindIdealHardwareResolution() +static void runSession(const std::shared_ptr &session) { - int baseCX = (int)runContext.baseResolutionCX; - int baseCY = (int)runContext.baseResolutionCY; - - std::vector results; - - int pcores = os_get_physical_cores(); - int maxDataRate; - if (pcores >= 4) { - maxDataRate = int(runContext.baseResolutionCX * runContext.baseResolutionCY * 60 + 1000); - } else { - maxDataRate = 1280 * 720 * 30 + 1000; + pushEvent(session, "phase", "preflight", 0); + if (session->cancelRequested.load()) { + completeCancelled(session); + return; } - auto testRes = [&](long double div, int fps_num, int fps_den, bool force) { - if (results.size() >= 3) + pushEvent(session, "phase", "hardware", 15, "scratch_encoder_benchmark"); + const auto hardwareDeadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kHardwarePhaseTimeoutMs); + std::vector preparedLegs; + std::vector hardwareAssessments; + preparedLegs.reserve(session->legs.size()); + hardwareAssessments.reserve(session->legs.size()); + for (size_t index = 0; index < session->legs.size(); index++) { + preparedLegs.push_back(withOfflinePlatformCaps(session->legs[index])); + hardwareAssessments.push_back(assessHardware(session, preparedLegs.back(), hardwareDeadline)); + const HardwareAssessment &assessment = hardwareAssessments.back(); + if (assessment.cancelled || session->cancelRequested.load()) { + completeCancelled(session); return; - - if (!fps_num || !fps_den) { - fps_num = runContext.specificFPSNum; - fps_den = runContext.specificFPSDen; } - - long double fps = ((long double)fps_num / (long double)fps_den); - - int cx = int((long double)baseCX / div); - int cy = int((long double)baseCY / div); - - long double rate = (long double)cx * (long double)cy * fps; - if (!force && rate > maxDataRate) + const std::string code = assessment.reason.empty() ? "hardware_benchmark_passed" : assessment.reason; + const double progress = 15.0 + (15.0 * (double)(index + 1) / (double)session->legs.size()); + pushEvent(session, "progress", "hardware", progress, code, preparedLegs.back().legId); + } + + ProbeResult probeResult; + bool usedActiveProbe = false; + if (session->activeProbeEligible) { + pushEvent(session, "phase", "bandwidth", 30, {}, session->legs[0].legId, "active"); + probeResult = runTwitchProbe(session, session->legs[0]); + if (probeResult.cancelled || session->cancelRequested.load()) { + completeCancelled(session); return; - - int minBitrate = int(EstimateMinBitrate(cx, cy, fps_num, fps_den) * 114 / 100); - if (runContext.type == Type::Recording) - force = true; - if (force || runContext.idealBitrate >= minBitrate) - results.emplace_back(cx, cy, fps_num, fps_den); - }; - - if (runContext.specificFPSNum && runContext.specificFPSDen) { - testRes(1.0, 0, 0, false); - testRes(1.5, 0, 0, false); - testRes(1.0 / 0.6, 0, 0, false); - testRes(2.0, 0, 0, false); - testRes(2.25, 0, 0, true); - } else { - testRes(1.0, 60, 1, false); - testRes(1.0, 30, 1, false); - testRes(1.5, 60, 1, false); - testRes(1.5, 30, 1, false); - testRes(1.0 / 0.6, 60, 1, false); - testRes(1.0 / 0.6, 30, 1, false); - testRes(2.0, 60, 1, false); - testRes(2.0, 30, 1, false); - testRes(2.25, 60, 1, false); - testRes(2.25, 30, 1, true); - } - - int minArea = 960 * 540 + 1000; - - if (!runContext.specificFPSNum && runContext.preferHighFPS && results.size() > 1) { - Result &result1 = results[0]; - Result &result2 = results[1]; - - if (result1.fps_num == 30 && result2.fps_num == 60) { - int nextArea = result2.cx * result2.cy; - if (nextArea >= minArea) - results.erase(results.begin()); } - } - - Result result = results.front(); - runContext.idealResolutionCX = result.cx; - runContext.idealResolutionCY = result.cy; - - runContext.idealFPSNum = result.fps_num; - runContext.idealFPSDen = result.fps_den; -} - -bool autoConfig::TestSoftwareEncoding() -{ - OBSEncoder vencoder = obs_video_encoder_create(ADVANCED_ENCODER_X264, "test_x264", nullptr, nullptr); - OBSEncoder aencoder = obs_audio_encoder_create("ffmpeg_aac", "test_aac", nullptr, 0, nullptr); - OBSOutput output = obs_output_create("null_output", "null", nullptr, nullptr); - - /* -----------------------------------*/ - /* configure settings */ - - OBSData aencoder_settings = obs_data_create(); - OBSData vencoder_settings = obs_data_create(); - obs_data_release(aencoder_settings); - obs_data_release(vencoder_settings); - obs_data_set_int(aencoder_settings, "bitrate", 32); - - if (runContext.type != Type::Recording) { - obs_data_set_int(vencoder_settings, "keyint_sec", 2); - obs_data_set_int(vencoder_settings, "bitrate", runContext.idealBitrate); - obs_data_set_string(vencoder_settings, "rate_control", "CBR"); - obs_data_set_string(vencoder_settings, "profile", "main"); - obs_data_set_string(vencoder_settings, "preset", "veryfast"); + usedActiveProbe = probeResult.success; + if (!probeResult.success) + pushEvent(session, "progress", "bandwidth", 65, "twitch_probe_failed_estimate_used", session->legs[0].legId, "estimated"); } else { - obs_data_set_int(vencoder_settings, "crf", 20); - obs_data_set_string(vencoder_settings, "rate_control", "CRF"); - obs_data_set_string(vencoder_settings, "profile", "high"); - obs_data_set_string(vencoder_settings, "preset", "veryfast"); + session->probe.streamKey.clear(); + pushEvent(session, "progress", "bandwidth", 65, session->activeProbeDenialReason.empty() ? "estimate_only" : session->activeProbeDenialReason, + {}, "estimated"); } - /* -----------------------------------*/ - /* apply settings */ - - obs_encoder_update(vencoder, vencoder_settings); - obs_encoder_update(aencoder, aencoder_settings); - - /* -----------------------------------*/ - /* connect encoders/services/outputs */ - - obs_output_set_video_encoder(output, vencoder); - obs_output_set_audio_encoder(output, aencoder, 0); - - /* -----------------------------------*/ - /* connect signals */ - - auto on_stopped = [&]() { - std::unique_lock lock(m); - cv.notify_one(); - }; - - using on_stopped_t = decltype(on_stopped); - - auto pre_on_stopped = [](void *data, calldata_t *) { - on_stopped_t &on_stopped = *reinterpret_cast(data); - on_stopped(); - }; - - signal_handler *sh = obs_output_get_signal_handler(output); - signal_handler_connect(sh, "deactivate", pre_on_stopped, &on_stopped); - - /* -----------------------------------*/ - /* calculate starting resolution */ - - int baseCX = int(runContext.baseResolutionCX); - int baseCY = int(runContext.baseResolutionCY); - - /* -----------------------------------*/ - /* calculate starting test rates */ - - int pcores = os_get_physical_cores(); - int lcores = os_get_logical_cores(); - int maxDataRate; - if (lcores > 8 || pcores > 4) { - /* superb */ - maxDataRate = int(runContext.baseResolutionCX * runContext.baseResolutionCY * 60 + 1000); - - } else if (lcores > 4 && pcores == 4) { - /* great */ - maxDataRate = int(runContext.baseResolutionCX * runContext.baseResolutionCY * 60 + 1000); - - } else if (pcores == 4) { - /* okay */ - maxDataRate = int(runContext.baseResolutionCX * runContext.baseResolutionCY * 30 + 1000); - - } else { - /* toaster */ - maxDataRate = 960 * 540 * 30 + 1000; + if (session->cancelRequested.load()) { + completeCancelled(session); + return; } - /* -----------------------------------*/ - /* perform tests */ - - std::vector results; - int i = 0; - int count = 1; - - obs_video_info *ovi = obs_create_video_info(); - - auto testRes = [&](long double div, int fps_num, int fps_den, bool force) { - int per = ++i * 100 / count; - - /* no need for more than 3 tests max */ - if (results.size() >= 3) - return true; - - if (!fps_num || !fps_den) { - fps_num = runContext.specificFPSNum; - fps_den = runContext.specificFPSDen; - } - - long double fps = ((long double)fps_num / (long double)fps_den); - - int cx = int((long double)baseCX / div); - int cy = int((long double)baseCY / div); - - if (!force && runContext.type != Type::Recording) { - int est = int(EstimateMinBitrate(cx, cy, fps_num, fps_den)); - if (est > runContext.idealBitrate) - return true; - } - - long double rate = (long double)cx * (long double)cy * fps; - if (!force && rate > maxDataRate) - return true; - - obs_video_info video = *ovi; - video.base_width = 1280; - video.base_height = 720; - video.output_width = cx; - video.output_height = cy; - video.output_format = VIDEO_FORMAT_NV12; - video.fps_num = fps_num; - video.fps_den = fps_den; - video.initialized = true; - int ret = obs_set_video_info(ovi, &video); - if (ret != OBS_VIDEO_SUCCESS) { - blog(LOG_ERROR, "[VIDEO_CANVAS] Failed to update video info %08X", ovi); - return false; + pushEvent(session, "phase", "recommendation", 75); + std::vector recommendations; + for (size_t index = 0; index < preparedLegs.size(); index++) { + const LegRequest &leg = preparedLegs[index]; + const HardwareAssessment &hardware = hardwareAssessments[index]; + Recommendation recommendation; + recommendation.legId = leg.legId; + recommendation.display = leg.display; + recommendation.destinations = leg.destinations; + recommendation.limits = leg.limits; + recommendation.value = estimateRecommendation(leg, hardware); + recommendation.reason = defaultEstimateReason(session->topology, leg); + if (!hardware.passed || hardware.constrained) { + recommendation.confidence = hardware.passed ? "medium" : "low"; + recommendation.reason = hardware.reason; } - obs_encoder_set_audio(aencoder, obs_get_audio()); - - obs_encoder_update(vencoder, vencoder_settings); - obs_encoder_set_video_mix(vencoder, obs_video_mix_get(ovi, OBS_MAIN_VIDEO_RENDERING)); - - obs_output_set_audio_encoder(output, aencoder, 0); - obs_output_set_video_encoder(output, vencoder); - - std::unique_lock ul(m); - if (cancel) - return false; - - if (!obs_output_start(output)) { - return false; + if (usedActiveProbe && leg.legId == session->probe.legId) { + recommendation.measurementMode = "active"; + if (hardware.passed && !hardware.constrained) { + recommendation.confidence = "high"; + recommendation.reason.clear(); + } + uint64_t safeKbps = probeResult.measuredKbps * 70ULL / 100ULL; + if (probeResult.platformCapKbps > 0) + safeKbps = std::min(safeKbps, (uint64_t)probeResult.platformCapKbps); + if (leg.limits.maxBitrateKbps > 0) + safeKbps = std::min(safeKbps, (uint64_t)leg.limits.maxBitrateKbps); + // Never turn a low measurement into a higher recommendation merely to + // satisfy a nominal bitrate floor. Surface the low-confidence result and + // let Desktop decide how to explain an insufficient connection. + if (safeKbps < 500) { + recommendation.confidence = "low"; + recommendation.reason = "insufficient_bandwidth"; + } + recommendation.value.bitrateKbps = (int)std::clamp(safeKbps, 1, kProbeMaximumBitrateKbps); + } else if (session->activeProbeEligible && leg.legId == session->probe.legId && !probeResult.success) { + recommendation.confidence = "low"; + recommendation.reason = "probe_failed"; } - cv.wait_for(ul, std::chrono::seconds(5)); - - obs_output_stop(output); - cv.wait(ul); - - int skipped = (int)video_output_get_skipped_frames(obs_get_video()); - if (force || skipped <= 10) - results.emplace_back(cx, cy, fps_num, fps_den); - - return !cancel; - }; - - if (runContext.specificFPSNum && runContext.specificFPSDen) { - count = 5; - if (!testRes(1.0, 0, 0, false)) - return false; - if (!testRes(1.5, 0, 0, false)) - return false; - if (!testRes(1.0 / 0.6, 0, 0, false)) - return false; - if (!testRes(2.0, 0, 0, false)) - return false; - if (!testRes(2.25, 0, 0, true)) - return false; - } else { - count = 10; - if (!testRes(1.0, 60, 1, false)) - return false; - if (!testRes(1.0, 30, 1, false)) - return false; - if (!testRes(1.5, 60, 1, false)) - return false; - if (!testRes(1.5, 30, 1, false)) - return false; - if (!testRes(1.0 / 0.6, 60, 1, false)) - return false; - if (!testRes(1.0 / 0.6, 30, 1, false)) - return false; - if (!testRes(2.0, 60, 1, false)) - return false; - if (!testRes(2.0, 30, 1, false)) - return false; - if (!testRes(2.25, 60, 1, false)) - return false; - if (!testRes(2.25, 30, 1, true)) - return false; + recommendations.push_back(std::move(recommendation)); } - /* -----------------------------------*/ - /* find preferred settings */ - - int minArea = 960 * 540 + 1000; - - if (!runContext.specificFPSNum && runContext.preferHighFPS && results.size() > 1) { - Result &result1 = results[0]; - Result &result2 = results[1]; - - if (result1.fps_num == 30 && result2.fps_num == 60) { - int nextArea = result2.cx * result2.cy; - if (nextArea >= minArea) - results.erase(results.begin()); - } + { + std::lock_guard lock(session->mutex); + session->resultJson = serializeResult(*session, "complete", recommendations); } + session->state.store(SessionState::Complete); + pushEvent(session, "result", "recommendation", 95); + pushEvent(session, "complete", "cleanup", 100); +} - Result result = results.front(); - runContext.idealResolutionCX = result.cx; - runContext.idealResolutionCY = result.cy; - - runContext.idealFPSNum = result.fps_num; - runContext.idealFPSDen = result.fps_den; - - long double fUpperBitrate = EstimateUpperBitrate(result.cx, result.cy, result.fps_num, result.fps_den); - - int upperBitrate = int(floor(fUpperBitrate / 50.0l) * 50.0l); - - if (runContext.streamingEncoder != Encoder::x264) { - upperBitrate *= 114; - upperBitrate /= 100; +static bool requestCancellation(const std::shared_ptr &session) +{ + std::unique_lock lifecycleLock(session->lifecycleMutex); + const SessionState state = session->state.load(); + if (state == SessionState::Created) { + session->cancelRequested.store(true); + completeCancelled(session); + return true; } + if (state != SessionState::Running) + return true; - if (runContext.idealBitrate > upperBitrate) - runContext.idealBitrate = upperBitrate; - - obs_output_release(output); - obs_encoder_release(vencoder); - obs_encoder_release(aencoder); - - int ret = obs_remove_video_info(ovi); - if (ret != OBS_VIDEO_SUCCESS) { - blog(LOG_ERROR, "[VIDEO_CANVAS] Failed to remove video info after TestSoftwareEncoding, %08X", ovi); + session->cancelRequested.store(true); + { + std::lock_guard lock(session->probeMutex); + if (session->activeProbeOutput) + obs_output_force_stop(session->activeProbeOutput); } - runContext.softwareTested = true; + if (session->worker.valid() && session->worker.wait_for(std::chrono::milliseconds(kCancelTimeoutMs)) != std::future_status::ready) { + pushEvent(session, "error", "cleanup", 100, "cleanup_timeout"); + return false; + } return true; } -void autoConfig::TestStreamEncoderThread() -{ - eventsMutex.lock(); - events.push(AutoConfigInfo("starting_step", "runContext.streamingEncoder_test", 0)); - eventsMutex.unlock(); - - autoConfig::ResourceSampler sampler; - sampler.start("stream_encoder", std::chrono::milliseconds(250)); - - TestHardwareEncoding(); +} // namespace - if (!runContext.softwareTested) { - if (!runContext.preferHardware || !runContext.hardwareEncodingAvailable) { - if (!TestSoftwareEncoding()) { - return; - } - } - } - - if (runContext.preferHardware && !runContext.softwareTested && runContext.hardwareEncodingAvailable) - FindIdealHardwareResolution(); +void Register(ipc::server &srv) +{ + auto collection = std::make_shared("AutoConfig"); - if (!runContext.softwareTested) { - if (runContext.nvencAvailable) - runContext.streamingEncoder = Encoder::NVENC; - else if (runContext.qsvAvailable) - runContext.streamingEncoder = Encoder::QSV; - else if (runContext.vceAvailable) - runContext.streamingEncoder = Encoder::AMD; - // HW encoding seems to not be stable on Mac - // else if (appleHWAvailable) - // runContext.streamingEncoder = Encoder::appleHW; - } else { - runContext.streamingEncoder = Encoder::x264; - } + collection->register_function(std::make_shared("GetAutoConfigCapabilities", std::vector{}, GetCapabilities)); + collection->register_function(std::make_shared("CreateAutoConfigSession", std::vector{ipc::type::String}, CreateSession)); + collection->register_function(std::make_shared("StartAutoConfigSession", std::vector{ipc::type::String}, StartSession)); + collection->register_function(std::make_shared("QueryAutoConfigSession", std::vector{ipc::type::String}, QuerySession)); + collection->register_function(std::make_shared("GetAutoConfigResult", std::vector{ipc::type::String}, GetResult)); + collection->register_function(std::make_shared("CancelAutoConfigSession", std::vector{ipc::type::String}, CancelSession)); + collection->register_function(std::make_shared("CloseAutoConfigSession", std::vector{ipc::type::String}, CloseSession)); - // Surface encoder detection + chosen streaming encoder for the new POC UI. - { - const char *chosenId = GetEncoderId(runContext.streamingEncoder); - obs_data_t *p = obs_data_create(); - obs_data_set_bool(p, "hardwareEncodingAvailable", runContext.hardwareEncodingAvailable); - obs_data_set_bool(p, "nvenc", runContext.nvencAvailable); - obs_data_set_bool(p, "qsv", runContext.qsvAvailable); - obs_data_set_bool(p, "vce", runContext.vceAvailable); - obs_data_set_bool(p, "apple", runContext.appleAvailable); - obs_data_set_bool(p, "softwareTested", runContext.softwareTested); - obs_data_set_string(p, "chosenStreamingEncoder", chosenId ? chosenId : ""); - std::string payload = obs_data_get_json(p); - obs_data_release(p); - - std::lock_guard lock(eventsMutex); - events.push(AutoConfigInfo("encoder_detection", "summary", 100, payload)); - } - - recordResourceWindow(sampler.stop()); - - eventsMutex.lock(); - events.push(AutoConfigInfo("stopping_step", "runContext.streamingEncoder_test", 100)); - eventsMutex.unlock(); + srv.register_collection(collection); } -void autoConfig::TestRecordingEncoderThread() +void GetCapabilities(void *, const int64_t, const std::vector &, std::vector &rval) { - eventsMutex.lock(); - events.push(AutoConfigInfo("starting_step", "runContext.recordingEncoder_test", 0)); - eventsMutex.unlock(); - - autoConfig::ResourceSampler sampler; - sampler.start("recording_encoder", std::chrono::milliseconds(250)); - - TestHardwareEncoding(); + static const char *capabilities = + R"({"apiVersion":2,"resultSchemaVersion":1,"previewApplySplit":true,"awaitableCancel":true,"perUploadLegResults":true,"desktopOwnedApply":true,"bandwidthModes":["twitch-standard-active","estimate"]})"; + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); + rval.push_back(ipc::value(capabilities)); +} - if (!runContext.hardwareEncodingAvailable && !runContext.softwareTested) { - if (!TestSoftwareEncoding()) { - return; - } +void CreateSession(void *, const int64_t, const std::vector &args, std::vector &rval) +{ + if (args.size() != 1) { + returnError(rval, "CreateAutoConfigSession expects request JSON"); + return; + } + if (shuttingDown.load()) { + returnError(rval, "autoconfig_shutting_down"); + return; } - if (runContext.type == Type::Recording && runContext.hardwareEncodingAvailable) - FindIdealHardwareResolution(); - - runContext.recordingQuality = Quality::High; - - bool recordingOnly = runContext.type == Type::Recording; - - if (runContext.hardwareEncodingAvailable) { - if (runContext.nvencAvailable) - runContext.recordingEncoder = Encoder::NVENC; - else if (runContext.qsvAvailable) - runContext.recordingEncoder = Encoder::QSV; - else if (runContext.vceAvailable) - runContext.recordingEncoder = Encoder::AMD; - // HW encoding seems to not be stable on Mac - // else if (appleHWAvailable) - // runContext.recordingEncoder = Encoder::appleHW; - } else { - runContext.recordingEncoder = Encoder::x264; + auto session = std::make_shared(); + session->id = "autoconfig-v2-" + std::to_string(os_gettime_ns()) + "-" + std::to_string(nextSessionId.fetch_add(1)); + std::string error; + if (!parseRequest(args[0].value_str, *session, error)) { + returnError(rval, error.c_str()); + return; } - if (runContext.recordingEncoder != Encoder::NVENC) { - if (!recordingOnly) { - runContext.recordingEncoder = Encoder::Stream; - runContext.recordingQuality = Quality::Stream; + { + std::lock_guard lock(sessionsMutex); + if (shuttingDown.load()) { + returnError(rval, "autoconfig_shutting_down"); + return; } + if (activeSession) { + returnError(rval, "autoconfig_session_busy"); + return; + } + activeSession = session; } - recordResourceWindow(sampler.stop()); - - eventsMutex.lock(); - events.push(AutoConfigInfo("stopping_step", "runContext.recordingEncoder_test", 100)); - eventsMutex.unlock(); + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); + rval.push_back(ipc::value(session->id)); } -inline const char *GetEncoderId(Encoder enc) +void StartSession(void *, const int64_t, const std::vector &args, std::vector &rval) { - switch (enc) { - case Encoder::NVENC: - return SIMPLE_ENCODER_NVENC; - case Encoder::QSV: - return SIMPLE_ENCODER_QSV; - case Encoder::AMD: - return SIMPLE_ENCODER_AMD; - case Encoder::Apple: - return SIMPLE_ENCODER_APPLE_H264; - default: - return SIMPLE_ENCODER_X264; + if (args.size() != 1) { + returnError(rval, "StartAutoConfigSession expects sessionId"); + return; } -}; - -bool autoConfig::CheckSettings(void) -{ -#ifdef OLD_BANDWIDTH_TEST //deprecated - OBSData settings = obs_data_create(); - - obs_data_set_string(settings, "service", serviceName.c_str()); - obs_data_set_string(settings, "server", server.c_str()); - - std::string testKey = key; - - if (serviceName.compare("Twitch") == 0) { - testKey += "?bandwidthtest"; + auto session = findSession(args[0].value_str); + if (!session) { + returnError(rval, "autoconfig_session_not_found"); + return; } - - obs_data_set_string(settings, "key", testKey.c_str()); - - OBSService service = obs_service_create("rtmp_common", "serviceTest", settings, NULL); - - if (!service) { - eventsMutex.lock(); - events.push(AutoConfigInfo("error", "invalid_service", 100)); - eventsMutex.unlock(); - return false; + std::lock_guard lifecycleLock(session->lifecycleMutex); + SessionState expected = SessionState::Created; + if (!session->state.compare_exchange_strong(expected, SessionState::Running)) { + returnError(rval, "autoconfig_session_already_started"); + return; } - - obs_video_info video = {0}; - bool have_users_info = obs_get_video_info(&video); - - obs_video_info *ovi = obs_create_video_info(); - - if (!have_users_info) { - video = *ovi; + try { + session->worker = std::async(std::launch::async, [session]() { + try { + runSession(session); + } catch (...) { + completeFailed(session, "autoconfig_worker_failed"); + } + }); + } catch (...) { + completeFailed(session, "autoconfig_worker_launch_failed"); + returnError(rval, "autoconfig_worker_launch_failed"); + return; } + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); +} - video.base_width = 1280; - video.base_height = 720; - video.output_width = (uint32_t)runContext.idealResolutionCX; - video.output_height = (uint32_t)runContext.idealResolutionCY; - video.fps_num = runContext.idealFPSNum; - video.fps_den = 1; - video.initialized = true; - int ret = obs_set_video_info(ovi, &video); - if (ret != OBS_VIDEO_SUCCESS) { - eventsMutex.lock(); - events.push(AutoConfigInfo("error", "invalid_video_settings", 100)); - eventsMutex.unlock(); - obs_remove_video_info(ovi); - return false; +void QuerySession(void *, const int64_t, const std::vector &args, std::vector &rval) +{ + if (args.size() != 1) { + returnError(rval, "QueryAutoConfigSession expects sessionId"); + return; + } + auto session = findSession(args[0].value_str); + if (!session) { + returnError(rval, "autoconfig_session_not_found"); + return; } - OBSEncoder vencoder = obs_video_encoder_create(GetEncoderId(runContext.streamingEncoder), "test_encoder", nullptr, nullptr); - OBSEncoder aencoder = obs_audio_encoder_create("ffmpeg_aac", "test_aac", nullptr, 0, nullptr); - OBSOutput output = obs_output_create("rtmp_output", "test_stream", nullptr, nullptr); - - OBSData service_settings = obs_data_create(); - OBSData vencoder_settings = obs_data_create(); - OBSData aencoder_settings = obs_data_create(); - OBSData output_settings = obs_data_create(); - obs_data_release(service_settings); - obs_data_release(vencoder_settings); - obs_data_release(aencoder_settings); - obs_data_release(output_settings); - - obs_data_set_int(vencoder_settings, "bitrate", runContext.idealBitrate); - obs_data_set_string(vencoder_settings, "rate_control", "CBR"); - obs_data_set_string(vencoder_settings, "preset", "veryfast"); - obs_data_set_int(vencoder_settings, "keyint_sec", 2); - - obs_data_set_int(aencoder_settings, "bitrate", 32); - - /* -----------------------------------*/ - /* apply settings */ - - obs_service_apply_encoder_settings(service, vencoder_settings, aencoder_settings); - - obs_encoder_update(vencoder, vencoder_settings); - obs_encoder_update(aencoder, aencoder_settings); - - obs_encoder_set_video_mix(vencoder, obs_video_mix_get(ovi, OBS_MAIN_VIDEO_RENDERING)); - obs_encoder_set_audio(aencoder, obs_get_audio()); - - /* -----------------------------------*/ - /* connect encoders/services/outputs */ - - obs_output_set_video_encoder(output, vencoder); - obs_output_set_audio_encoder(output, aencoder, 0); - - obs_output_update(output, output_settings); - - obs_output_set_service(output, service); - - /* -----------------------------------*/ - /* connect signals */ - bool success = true; - - auto on_started = [&]() { - std::unique_lock lock(m); - success = true; - cv.notify_one(); - }; - - auto on_stopped = [&]() { - std::unique_lock lock(m); - cv.notify_one(); - }; - - auto on_deactivate = [&]() { cv.notify_one(); }; - - using on_started_t = decltype(on_started); - using on_stopped_t = decltype(on_stopped); - using on_deactivate_t = decltype(on_deactivate); - - auto pre_on_started = [](void *data, calldata_t *) { - on_started_t &on_started = *reinterpret_cast(data); - on_started(); - }; - - auto pre_on_stopped = [](void *data, calldata_t *) { - on_stopped_t &on_stopped = *reinterpret_cast(data); - on_stopped(); - }; - - auto pre_on_deactivate = [](void *data, calldata_t *) { - on_deactivate_t &on_deactivate = *reinterpret_cast(data); - on_deactivate(); - }; - - signal_handler *sh = obs_output_get_signal_handler(output); - signal_handler_connect(sh, "start", pre_on_started, &on_started); - signal_handler_connect(sh, "stop", pre_on_stopped, &on_stopped); - signal_handler_connect(sh, "deactivate", pre_on_deactivate, &on_deactivate); - - std::unique_lock ul(m); - if (!cancel) { - /* -----------------------------------*/ - /* start and wait to stop */ + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); + std::lock_guard lock(session->mutex); + if (session->events.empty()) + return; - if (!obs_output_start(output)) { - } else { - cv.wait_for(ul, std::chrono::seconds(4)); + const SessionEvent &event = session->events.front(); + rval.push_back(ipc::value((uint32_t)kSchemaVersion)); + rval.push_back(ipc::value(session->id)); + rval.push_back(ipc::value(event.sequence)); + rval.push_back(ipc::value(event.type)); + rval.push_back(ipc::value(event.phase)); + rval.push_back(ipc::value(event.progress)); + rval.push_back(ipc::value(event.code)); + rval.push_back(ipc::value(event.legId)); + rval.push_back(ipc::value(event.measurementMode)); + session->events.pop(); +} - obs_output_stop(output); - //wait for the output to stop - cv.wait(ul); - //wait for the output to deactivate - cv.wait(ul); - } - } else { - success = false; +void GetResult(void *, const int64_t, const std::vector &args, std::vector &rval) +{ + if (args.size() != 1) { + returnError(rval, "GetAutoConfigResult expects sessionId"); + return; } - - obs_output_release(output); - obs_encoder_release(vencoder); - obs_encoder_release(aencoder); - obs_service_release(service); - - ret = obs_remove_video_info(ovi); - if (ret != OBS_VIDEO_SUCCESS) { - blog(LOG_ERROR, "[VIDEO_CANVAS] Failed to remove video info after CheckSettings, %08X", ovi); + auto session = findSession(args[0].value_str); + if (!session) { + returnError(rval, "autoconfig_session_not_found"); + return; } - return success; -#endif //deprecated old api bandwidth test - return true; + std::lock_guard lock(session->mutex); + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); + rval.push_back(ipc::value(session->resultJson)); } -void autoConfig::SetDefaultSettings(void) +void CancelSession(void *, const int64_t, const std::vector &args, std::vector &rval) { - eventsMutex.lock(); - events.push(AutoConfigInfo("starting_step", "setting_default_settings", 0)); - eventsMutex.unlock(); - - runContext.idealResolutionCX = 1280; - runContext.idealResolutionCY = 720; - runContext.idealFPSNum = 30; - runContext.recordingQuality = Quality::High; - runContext.idealBitrate = 4500; - runContext.streamingEncoder = Encoder::x264; - runContext.recordingEncoder = Encoder::Stream; - - eventsMutex.lock(); - events.push(AutoConfigInfo("stopping_step", "setting_default_settings", 100)); - eventsMutex.unlock(); + if (args.size() != 1) { + returnError(rval, "CancelAutoConfigSession expects sessionId"); + return; + } + auto session = findSession(args[0].value_str); + if (!session) { + returnError(rval, "autoconfig_session_not_found"); + return; + } + if (!requestCancellation(session)) { + returnError(rval, "autoconfig_cleanup_timeout"); + return; + } + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); } -// Push the chosen values from runContext into the streaming targets the -// frontend passed to InitializeAutoConfig. No basic.ini writes — APIv2 contract -// is that the frontend re-fetches via Get*() after seeing the "done" event. -// -// Order matters: obs_set_video_info fails while any output is still active, so we -// stop test outputs first, then mutate the video context, then service / encoders. -static void applyResults() +void CloseSession(void *, const int64_t, const std::vector &args, std::vector &rval) { - // Resolve the streaming targets stored in runContext. Skip ids that no - // longer resolve (object was destroyed mid-run). - struct StreamingTarget { - osn::Streaming *streaming; - uint64_t id; - }; - std::vector streamingTargets; - - for (uint64_t uid : runContext.targetStreamingIds) { - osn::Streaming *s = osn::IStreaming::Manager::GetInstance().find(uid); - if (s) - streamingTargets.push_back({s, uid}); + if (args.size() != 1) { + returnError(rval, "CloseAutoConfigSession expects sessionId"); + return; } - - // Collect video contexts referenced by the streaming targets (deduplicated). - std::set videoContexts; - for (auto &st : streamingTargets) { - obs_video_info *v = st.streaming->GetCanvas(); - if (v) - videoContexts.insert(v); + auto session = findSession(args[0].value_str); + if (!session) { + // Idempotent close: an already-closed/missing session is success. + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); + return; } - // Defensive — stop all streaming outputs before touching video context. - for (auto &st : streamingTargets) { - if (st.streaming->GetOutput() && obs_output_active(st.streaming->GetOutput())) - obs_output_stop(st.streaming->GetOutput()); + if (!requestCancellation(session)) { + returnError(rval, "autoconfig_cleanup_timeout"); + return; } - // obs_output_stop() is asynchronous; wait for outputs to fully deactivate - // before mutating the video context, or obs_set_video_info() below can return - // OBS_VIDEO_CURRENTLY_ACTIVE (the same race Streaming::CleanTestMode guards). { - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); - for (auto &st : streamingTargets) { - obs_output_t *output = st.streaming->GetOutput(); - while (output && obs_output_active(output) && std::chrono::steady_clock::now() < deadline) { - std::this_thread::sleep_for(std::chrono::milliseconds(20)); - } - } - } - - // 1. Resolution / FPS — applied to each video context referenced by a streaming - // target. Must run before encoders (encoder video-mix indices are tied to the - // video context). - for (auto *video : videoContexts) { - AutoconfigRun::VideoDecision vd; - vd.contextPtr = video; - vd.cxBefore = video->output_width; - vd.cyBefore = video->output_height; - vd.fpsNumBefore = video->fps_num; - vd.fpsDenBefore = video->fps_den; - - obs_video_info v = *video; - v.fps_num = (uint32_t)runContext.idealFPSNum; - v.fps_den = (uint32_t)(runContext.idealFPSDen ? runContext.idealFPSDen : 1); - v.output_width = ((uint32_t)runContext.idealResolutionCX) & 0xFFFFFFFC; - v.output_height = ((uint32_t)runContext.idealResolutionCY) & 0xFFFFFFFE; - - vd.cxAfter = v.output_width; - vd.cyAfter = v.output_height; - vd.fpsNumAfter = v.fps_num; - vd.fpsDenAfter = v.fps_den; - - blog(LOG_INFO, "applyResults: ctx=%p current=%ux%u@%u/%u requested=%ux%u@%u/%u", video, video->output_width, video->output_height, - video->fps_num, video->fps_den, v.output_width, v.output_height, v.fps_num, v.fps_den); - - // Skip the libobs call when nothing changes — the common case where - // autoconfig picks the resolution/FPS the canvas is already running at. - // obs_set_video_info would otherwise return OBS_VIDEO_CURRENTLY_ACTIVE - // for any active video context (e.g. running preview). - if (video->fps_num == v.fps_num && video->fps_den == v.fps_den && video->output_width == v.output_width && - video->output_height == v.output_height) { - blog(LOG_INFO, "applyResults: ctx=%p no change, skipping obs_set_video_info", video); - vd.skipped = true; - vd.obsSetVideoInfoRet = OBS_VIDEO_SUCCESS; - } else { - int ret = obs_set_video_info(video, &v); - vd.obsSetVideoInfoRet = ret; - blog(ret == OBS_VIDEO_SUCCESS ? LOG_INFO : LOG_WARNING, "applyResults: ctx=%p obs_set_video_info returned %d", video, ret); - if (ret != OBS_VIDEO_SUCCESS) { - std::lock_guard lock(eventsMutex); - events.push(AutoConfigInfo("error", "video_failed_ret_" + std::to_string(ret), 0)); - } - } - - runContext.videoDecisions.push_back(vd); - - // Emit video_decision event for the new POC UI. - std::ostringstream ptrOss; - ptrOss << "0x" << std::hex << reinterpret_cast(video); - std::string ptrStr = ptrOss.str(); - - obs_data_t *p = obs_data_create(); - obs_data_set_string(p, "contextPtr", ptrStr.c_str()); - obs_data_t *before = obs_data_create(); - obs_data_set_int(before, "cx", vd.cxBefore); - obs_data_set_int(before, "cy", vd.cyBefore); - obs_data_set_int(before, "fpsNum", vd.fpsNumBefore); - obs_data_set_int(before, "fpsDen", vd.fpsDenBefore); - obs_data_set_obj(p, "before", before); - obs_data_release(before); - obs_data_t *after = obs_data_create(); - obs_data_set_int(after, "cx", vd.cxAfter); - obs_data_set_int(after, "cy", vd.cyAfter); - obs_data_set_int(after, "fpsNum", vd.fpsNumAfter); - obs_data_set_int(after, "fpsDen", vd.fpsDenAfter); - obs_data_set_obj(p, "after", after); - obs_data_release(after); - obs_data_set_int(p, "ret", vd.obsSetVideoInfoRet); - obs_data_set_bool(p, "skipped", vd.skipped); - std::string payload = obs_data_get_json(p); - obs_data_release(p); - - std::lock_guard lock(eventsMutex); - events.push(AutoConfigInfo("video_decision", "ctx_" + ptrStr, 100, payload)); - } - - // 2. Per-target service URL + bitrate. Each target gets its own per-target - // result when the bandwidth test ran; otherwise falls back to the global values. - for (auto &st : streamingTargets) { - uint64_t targetBitrate = runContext.idealBitrate; - std::string targetServer = runContext.server; - for (auto &tr : runContext.targetResults) { - if (tr.streamingId == st.id) { - targetBitrate = tr.idealBitrate; - targetServer = tr.server; - break; - } - } - - // Service URL - if (st.streaming->service && !targetServer.empty()) { - obs_data_t *settings = obs_data_create(); - obs_data_set_string(settings, "server", targetServer.c_str()); - obs_service_update(st.streaming->service, settings); - obs_data_release(settings); - blog(LOG_INFO, "applyResults: target %llu: applied service server '%s'", st.id, targetServer.c_str()); - } - - // Streaming bitrate selection (Option 2 — heuristic as floor, not ceiling): - // 1. Read user's current bitrate (CleanTestMode restored it before this). - // 2. Compute OBS quality heuristic for the chosen res/FPS. - // 3. Pick max(user, heuristic) — respect user when above the heuristic; - // otherwise use the heuristic as the recommendation. - // 4. Cap by what the bandwidth test actually delivered (targetBitrate). - // 5. Cap by the per-platform service cap (Twitch etc.). - if (st.streaming->videoEncoder && targetBitrate > 0) { - int userBitrate = 0; - { - obs_data_t *s = obs_encoder_get_settings(st.streaming->videoEncoder); - userBitrate = (int)obs_data_get_int(s, "bitrate"); - obs_data_release(s); - } - - long double upperBitrate_d = EstimateUpperBitrate((int)runContext.idealResolutionCX, (int)runContext.idealResolutionCY, - runContext.idealFPSNum, runContext.idealFPSDen ? runContext.idealFPSDen : 1); - uint64_t heuristic = (uint64_t)(std::floor(upperBitrate_d / 50.0L) * 50.0L); - if (runContext.streamingEncoder != Encoder::x264 && heuristic > 0) { - heuristic = heuristic * 114ULL / 100ULL; - } - - uint64_t choseBeforeCaps = ((uint64_t)userBitrate > heuristic) ? (uint64_t)userBitrate : heuristic; - uint64_t afterMeasuredCap = choseBeforeCaps; - if (afterMeasuredCap > targetBitrate) - afterMeasuredCap = targetBitrate; - - uint64_t afterPlatformCap = afterMeasuredCap; - if (st.streaming->service) { - obs_data_t *capSettings = obs_data_create(); - obs_data_set_int(capSettings, "bitrate", (long long)afterMeasuredCap); - obs_service_apply_encoder_settings(st.streaming->service, capSettings, nullptr); - uint64_t platformReturned = (uint64_t)obs_data_get_int(capSettings, "bitrate"); - if (platformReturned > 0 && platformReturned < afterMeasuredCap) - afterPlatformCap = platformReturned; - obs_data_release(capSettings); - } - - uint64_t finalBitrate = afterPlatformCap; - - // Determine which cap was binding (in increasing-binding order). - std::string bindingCap; - if (afterPlatformCap < afterMeasuredCap) - bindingCap = "platform"; - else if (afterMeasuredCap < choseBeforeCaps) - bindingCap = "measured"; - else if ((uint64_t)userBitrate > heuristic) - bindingCap = "user"; - else - bindingCap = "heuristic"; - - blog(LOG_INFO, - "applyResults: target %llu picked %llu (user=%d heuristic=%llu choseBeforeCaps=%llu measured=%llu afterPlatform=%llu binding=%s)", - st.id, finalBitrate, userBitrate, heuristic, choseBeforeCaps, targetBitrate, afterPlatformCap, bindingCap.c_str()); - - obs_data_t *encSettings = obs_data_create(); - obs_data_set_int(encSettings, "bitrate", (long long)finalBitrate); - obs_encoder_update(st.streaming->videoEncoder, encSettings); - obs_data_release(encSettings); - blog(LOG_INFO, "applyResults: target %llu: applied video encoder bitrate %llu", st.id, finalBitrate); - - // Encoder id snapshot for the selection record + change log. - std::string currentEncoderId, chosenEncoderId; - bool encoderChanged = false; - if (st.streaming->videoEncoder) { - const char *cur = obs_encoder_get_id(st.streaming->videoEncoder); - const char *cho = GetEncoderId(runContext.streamingEncoder); - if (cur) - currentEncoderId = cur; - if (cho) - chosenEncoderId = cho; - if (cur && cho && strcmp(cur, cho) != 0) { - encoderChanged = true; - blog(LOG_INFO, "applyResults: target %llu: chosen encoder '%s' differs from current '%s'", st.id, cho, cur); - } - } - - AutoconfigRun::SelectionDetail sd; - sd.targetId = st.id; - sd.userBitrate = userBitrate; - sd.heuristic = heuristic; - sd.choseBeforeCaps = choseBeforeCaps; - sd.afterMeasuredCap = afterMeasuredCap; - sd.afterPlatformCap = afterPlatformCap; - sd.picked = finalBitrate; - sd.bindingCap = bindingCap; - sd.appliedServer = targetServer; - sd.currentEncoderId = currentEncoderId; - sd.chosenEncoderId = chosenEncoderId; - sd.encoderChanged = encoderChanged; - runContext.selectionDetails.push_back(sd); - - obs_data_t *p = obs_data_create(); - obs_data_set_int(p, "targetId", (long long)sd.targetId); - obs_data_set_int(p, "userBitrate", sd.userBitrate); - obs_data_set_int(p, "heuristic", (long long)sd.heuristic); - obs_data_set_int(p, "choseBeforeCaps", (long long)sd.choseBeforeCaps); - obs_data_set_int(p, "afterMeasuredCap", (long long)sd.afterMeasuredCap); - obs_data_set_int(p, "afterPlatformCap", (long long)sd.afterPlatformCap); - obs_data_set_int(p, "picked", (long long)sd.picked); - obs_data_set_string(p, "bindingCap", sd.bindingCap.c_str()); - obs_data_set_string(p, "appliedServer", sd.appliedServer.c_str()); - obs_data_set_string(p, "currentEncoderId", sd.currentEncoderId.c_str()); - obs_data_set_string(p, "chosenEncoderId", sd.chosenEncoderId.c_str()); - obs_data_set_bool(p, "encoderChanged", sd.encoderChanged); - std::string payload = obs_data_get_json(p); - obs_data_release(p); - - std::lock_guard lock(eventsMutex); - events.push(AutoConfigInfo("selection_decision", "target_" + std::to_string(sd.targetId), 100, payload)); + std::lock_guard lifecycleLock(session->lifecycleMutex); + if (session->worker.valid() && session->worker.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { + returnError(rval, "autoconfig_session_still_running"); + return; } + session->state.store(SessionState::Closed); } - - // 3. Recording bitrate — discover all recording targets, apply the global - // idealBitrate (minimum across all streaming targets) to each. - if (runContext.idealBitrate > 0) { - osn::IRecording::Manager::GetInstance().for_each([&](osn::FileOutput *fileOutput) { - auto *recording = static_cast(fileOutput); - if (recording && recording->videoEncoder) { - obs_data_t *encSettings = obs_data_create(); - obs_data_set_int(encSettings, "bitrate", (long long)runContext.idealBitrate); - obs_encoder_update(recording->videoEncoder, encSettings); - obs_data_release(encSettings); - blog(LOG_INFO, "applyResults: applied recording video encoder bitrate %llu", runContext.idealBitrate); - } - }); + { + std::lock_guard lock(sessionsMutex); + if (activeSession == session) + activeSession.reset(); } + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); } -void autoConfig::SaveStreamSettings() -{ - // Legacy IPC stage. The actual apply happens in SaveSettings (the terminal - // stage in the legacy frontend contract). Kept here as a no-op so callers that - // invoke both don't error. - std::lock_guard lock(eventsMutex); - events.push(AutoConfigInfo("stopping_step", "saving_service", 100)); -} - -void autoConfig::SaveSettings() +void Shutdown() { + shuttingDown.store(true); + std::shared_ptr session; { - std::lock_guard lock(eventsMutex); - events.push(AutoConfigInfo("starting_step", "applying_settings", 0)); + std::lock_guard lock(sessionsMutex); + session = activeSession; } + if (!session) + return; - applyResults(); + { + std::unique_lock lifecycleLock(session->lifecycleMutex); + const SessionState state = session->state.load(); + if (state == SessionState::Created) { + session->cancelRequested.store(true); + completeCancelled(session); + } else if (state == SessionState::Running) { + session->cancelRequested.store(true); + { + std::lock_guard lock(session->probeMutex); + if (session->activeProbeOutput) + obs_output_force_stop(session->activeProbeOutput); + } + } - runContext.runComplete = true; + // Shutdown is the final safety barrier before libobs teardown. Unlike the + // public cancellation API, it must not continue while scratch resources + // are still owned by the worker, even if cleanup takes longer than the + // normal cancellation deadline. + if (session->worker.valid()) + session->worker.wait(); + session->probe.streamKey.clear(); + session->state.store(SessionState::Closed); + } { - std::lock_guard lock(eventsMutex); - events.push(AutoConfigInfo("stopping_step", "applying_settings", 100)); - events.push(AutoConfigInfo("done", "", 100)); + std::lock_guard lock(sessionsMutex); + if (activeSession == session) + activeSession.reset(); } } + +} // namespace autoConfig diff --git a/obs-studio-server/source/nodeobs_autoconfig.h b/obs-studio-server/source/nodeobs_autoconfig.h index b82f60554..cdfdca4f0 100644 --- a/obs-studio-server/source/nodeobs_autoconfig.h +++ b/obs-studio-server/source/nodeobs_autoconfig.h @@ -1,59 +1,27 @@ /****************************************************************************** - Copyright (C) 2016-2019 by Streamlabs (General Workings Inc) + Copyright (C) 2026 by Streamlabs This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 2 of the License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - ******************************************************************************/ #pragma once -#include -#include -#pragma once -#include -#include -#include -#include -#include -#include "nodeobs_api.h" + +#include namespace autoConfig { + void Register(ipc::server &srv); -void InitializeAutoConfig(void *data, const int64_t id, const std::vector &args, std::vector &rval); -void StartBandwidthTest(void *data, const int64_t id, const std::vector &args, std::vector &rval); -void StartStreamEncoderTest(void *data, const int64_t id, const std::vector &args, std::vector &rval); -void StartRecordingEncoderTest(void *data, const int64_t id, const std::vector &args, std::vector &rval); -void StartCheckSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval); -void StartSetDefaultSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval); -void StartSaveStreamSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval); -void StartSaveSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval); -void TerminateAutoConfig(void *data, const int64_t id, const std::vector &args, std::vector &rval); -void Query(void *data, const int64_t id, const std::vector &args, std::vector &rval); -void GetAutoConfigSummary(void *data, const int64_t id, const std::vector &args, std::vector &rval); +void Shutdown(); + +void GetCapabilities(void *data, const int64_t id, const std::vector &args, std::vector &rval); +void CreateSession(void *data, const int64_t id, const std::vector &args, std::vector &rval); +void StartSession(void *data, const int64_t id, const std::vector &args, std::vector &rval); +void QuerySession(void *data, const int64_t id, const std::vector &args, std::vector &rval); +void GetResult(void *data, const int64_t id, const std::vector &args, std::vector &rval); +void CancelSession(void *data, const int64_t id, const std::vector &args, std::vector &rval); +void CloseSession(void *data, const int64_t id, const std::vector &args, std::vector &rval); -void StopThread(); -void FindIdealHardwareResolution(); -bool TestSoftwareEncoding(); -void TestBandwidthThread(); -void TestStreamEncoderThread(); -void TestRecordingEncoderThread(); -void SaveStreamSettings(); -void SaveSettings(); -bool CheckSettings(); -void SetDefaultSettings(); -void TestHardwareEncoding(); -bool CanTestServer(const char *server); -void WaitPendingTests(double timeout = 10); -int GetStartingBitrate(const std::string &serviceName); -void TestBandwidthThreadV2(void); } // namespace autoConfig diff --git a/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.cpp b/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.cpp deleted file mode 100644 index 93b95528e..000000000 --- a/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.cpp +++ /dev/null @@ -1,212 +0,0 @@ -/****************************************************************************** - Copyright (C) 2016-2019 by Streamlabs (General Workings Inc) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -******************************************************************************/ - -#include "nodeobs_autoconfig_resource_sampler.h" - -#include -#include - -#include -#include - -namespace autoConfig { - -namespace { -constexpr uint64_t kMiB = 1024ULL * 1024ULL; -} - -ResourceSampler::ResourceSampler() -{ - cpuInfo_ = os_cpu_usage_info_start(); - -#ifdef _WIN32 - // Pick the discrete GPU when present by iterating adapters and choosing the - // one with the largest dedicated VRAM. EnumAdapterByGpuPreference would be - // cleaner but lives on IDXGIFactory6 (dxgi1_6.h); the manual scan avoids - // the SDK version dependency. - Microsoft::WRL::ComPtr factory; - if (FAILED(CreateDXGIFactory1(IID_PPV_ARGS(&factory)))) - return; - - Microsoft::WRL::ComPtr best; - SIZE_T bestVram = 0; - for (UINT i = 0;; ++i) { - Microsoft::WRL::ComPtr adapter; - if (factory->EnumAdapters1(i, &adapter) == DXGI_ERROR_NOT_FOUND) - break; - DXGI_ADAPTER_DESC1 desc{}; - if (FAILED(adapter->GetDesc1(&desc))) - continue; - if (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) - continue; - if (desc.DedicatedVideoMemory > bestVram) { - bestVram = desc.DedicatedVideoMemory; - best = adapter; - } - } - if (!best) - return; - - Microsoft::WRL::ComPtr adapter3; - if (FAILED(best.As(&adapter3))) - return; - dxgiAdapter_ = adapter3; - gpuAvailable_ = true; -#endif -} - -ResourceSampler::~ResourceSampler() -{ - if (worker_.joinable()) { - workerStop_.store(true, std::memory_order_relaxed); - worker_.join(); - } - if (cpuInfo_) { - os_cpu_usage_info_destroy(cpuInfo_); - cpuInfo_ = nullptr; - } -} - -void ResourceSampler::start(const std::string &phase, std::chrono::milliseconds interval) -{ - phase_ = phase; - startTime_ = std::chrono::steady_clock::now(); - { - std::lock_guard lk(aggMutex_); - samples_.clear(); - // Pre-reserve enough for typical phase durations (5s bandwidth + buffer) - // at the 250ms cadence we use for background sampling. - samples_.reserve(32); - } - started_ = true; - - // First query after start returns no useful delta — discard it so the - // first real sample() call is meaningful. - if (cpuInfo_) - (void)os_cpu_usage_info_query(cpuInfo_); - - if (interval.count() > 0) { - // Take one sample synchronously before spawning the worker so a phase - // that completes faster than the worker's first scheduling slice still - // produces at least one data point. - sample(); - workerStop_.store(false, std::memory_order_relaxed); - worker_ = std::thread(&ResourceSampler::workerLoop, this, interval); - } -} - -void ResourceSampler::sample() -{ - if (!started_) - return; - - ResourceSample s; - // os_cpu_usage_info_query returns NaN when called too soon after start() - // (zero time delta) — treat that as 0% so downstream JSON stays numeric. - double cpu = cpuInfo_ ? os_cpu_usage_info_query(cpuInfo_) : 0.0; - s.cpuPct = (std::isnan(cpu) || std::isinf(cpu) || cpu < 0.0) ? 0.0 : cpu; - s.procRamMB = static_cast(os_get_proc_resident_size()) / static_cast(kMiB); - -#ifdef _WIN32 - if (dxgiAdapter_) { - DXGI_QUERY_VIDEO_MEMORY_INFO info{}; - if (SUCCEEDED(dxgiAdapter_->QueryVideoMemoryInfo(0, DXGI_MEMORY_SEGMENT_GROUP_LOCAL, &info))) { - s.gpuVramUsedMB = info.CurrentUsage / kMiB; - s.gpuVramBudgetMB = info.Budget / kMiB; - } - } -#endif - - std::lock_guard lk(aggMutex_); - samples_.push_back(s); -} - -void ResourceSampler::workerLoop(std::chrono::milliseconds interval) -{ - while (!workerStop_.load(std::memory_order_relaxed)) { - sample(); - // Sleep in small slices so stop() returns promptly when the test ends. - auto end = std::chrono::steady_clock::now() + interval; - while (std::chrono::steady_clock::now() < end) { - if (workerStop_.load(std::memory_order_relaxed)) - return; - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - } -} - -// Nearest-rank percentile on a sorted series. p is in [0, 100]. Returns the -// element at position ceil(p/100 * N) - 1, clamped — so p95 of 20 samples -// returns the 19th-of-20 sample, dropping a single top outlier. -template static T percentileNearestRank(std::vector sorted, double p) -{ - if (sorted.empty()) - return T{}; - std::sort(sorted.begin(), sorted.end()); - size_t n = sorted.size(); - double rank = (p / 100.0) * static_cast(n); - size_t idx = rank <= 0.0 ? 0 : static_cast(std::ceil(rank)) - 1; - if (idx >= n) - idx = n - 1; - return sorted[idx]; -} - -ResourceWindow ResourceSampler::stop() -{ - if (worker_.joinable()) { - workerStop_.store(true, std::memory_order_relaxed); - worker_.join(); - } - - ResourceWindow w; - w.phase = phase_; - w.durationMs = static_cast(std::chrono::duration_cast(std::chrono::steady_clock::now() - startTime_).count()); - w.gpuAvailable = gpuAvailable_; - - std::lock_guard lk(aggMutex_); - w.sampleCount = static_cast(samples_.size()); - if (!samples_.empty()) { - // Sort each component independently — CPU's p95 sample is rarely the - // same physical sample as RAM's p95. - std::vector cpu, ram; - std::vector gpuUsed, gpuBudget; - cpu.reserve(samples_.size()); - ram.reserve(samples_.size()); - gpuUsed.reserve(samples_.size()); - gpuBudget.reserve(samples_.size()); - for (auto &s : samples_) { - cpu.push_back(s.cpuPct); - ram.push_back(s.procRamMB); - gpuUsed.push_back(s.gpuVramUsedMB); - gpuBudget.push_back(s.gpuVramBudgetMB); - } - w.p50Sample.cpuPct = percentileNearestRank(cpu, 50.0); - w.p50Sample.procRamMB = percentileNearestRank(ram, 50.0); - w.p50Sample.gpuVramUsedMB = percentileNearestRank(gpuUsed, 50.0); - w.p50Sample.gpuVramBudgetMB = percentileNearestRank(gpuBudget, 50.0); - w.p95Sample.cpuPct = percentileNearestRank(cpu, 95.0); - w.p95Sample.procRamMB = percentileNearestRank(ram, 95.0); - w.p95Sample.gpuVramUsedMB = percentileNearestRank(gpuUsed, 95.0); - w.p95Sample.gpuVramBudgetMB = percentileNearestRank(gpuBudget, 95.0); - } - - started_ = false; - return w; -} - -} // namespace autoConfig diff --git a/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.h b/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.h deleted file mode 100644 index 82c2f1dba..000000000 --- a/obs-studio-server/source/nodeobs_autoconfig_resource_sampler.h +++ /dev/null @@ -1,112 +0,0 @@ -/****************************************************************************** - Copyright (C) 2016-2019 by Streamlabs (General Workings Inc) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -******************************************************************************/ - -#pragma once -#include -#include -#include -#include -#include -#include -#include - -#ifdef _WIN32 -#include -#include -#include -#endif - -struct os_cpu_usage_info; - -namespace autoConfig { - -// Single resource snapshot. cpuPct is the per-process CPU% reported by libOBS's -// os_cpu_usage_info; procRamMB is the resident set size in MiB. GPU VRAM fields -// are populated only on Windows when DXGI bring-up succeeded. -struct ResourceSample { - double cpuPct = 0.0; - double procRamMB = 0.0; - uint64_t gpuVramUsedMB = 0; - uint64_t gpuVramBudgetMB = 0; -}; - -// Aggregated samples for one autoconfig phase. Each component is sorted -// independently across the window and reduced to two percentiles: -// p50 — typical value during the test -// p95 — sustained ceiling, ignoring single-sample spikes from unrelated -// OS noise (e.g. a background process briefly using CPU) -// We deliberately do NOT surface min/max/avg: max is dominated by one-off -// spikes that aren't caused by autoconfig, and avg is hard to act on. -struct ResourceWindow { - std::string phase; - int sampleCount = 0; - int durationMs = 0; - ResourceSample p50Sample; - ResourceSample p95Sample; - bool gpuAvailable = false; -}; - -// Sampler owns one libOBS CPU info handle and (Windows only) one DXGI adapter -// reference. Two usage modes: -// -// 1. Manual: start(phase); sample(); ... ; stop(); -// 2. Background: start(phase, interval); [worker samples periodically]; stop(); -// -// In background mode start() spawns a thread that calls sample() every `interval` -// until stop() joins it. The bandwidth test uses mode 1 because it already has a -// 250ms wait loop; the encoder tests use mode 2 because their wait happens deep -// inside helpers we don't want to instrument. -// -// Don't share an instance across threads — sample() is not internally synchronized -// against external callers (the worker thread is the only sampler in mode 2). -class ResourceSampler { -public: - ResourceSampler(); - ~ResourceSampler(); - - ResourceSampler(const ResourceSampler &) = delete; - ResourceSampler &operator=(const ResourceSampler &) = delete; - - void start(const std::string &phase, std::chrono::milliseconds interval = std::chrono::milliseconds(0)); - void sample(); - ResourceWindow stop(); - - bool gpuAvailable() const { return gpuAvailable_; } - -private: - void workerLoop(std::chrono::milliseconds interval); - - std::string phase_; - std::chrono::steady_clock::time_point startTime_; - - std::mutex aggMutex_; - std::vector samples_; - - os_cpu_usage_info *cpuInfo_ = nullptr; - bool started_ = false; - - std::thread worker_; - std::atomic workerStop_{false}; - - bool gpuAvailable_ = false; -#ifdef _WIN32 - Microsoft::WRL::ComPtr dxgiAdapter_; -#endif -}; - -} // namespace autoConfig diff --git a/package.json b/package.json index d383e25f7..45956d251 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "@types/chai-subset": "^1.3.5", "@types/mocha": "^10.0.0", "@types/node": "^18.0.0", - "@types/node-media-server": "^2", "archiver": "^7.0.0", "chai": "^4.2.0", "chai-subset": "^1.6.0", @@ -45,7 +44,6 @@ "mocha": "^11.0.0", "mocha-junit-reporter": "^1.22.0", "node-addon-api": "^7.1.1", - "node-media-server": "2.7.2", "ts-node": "^7.0.1", "typedoc": "^0.28.0", "typedoc-plugin-markdown": "^4.0.0", diff --git a/tests/osn-tests/src/test_nodeobs_autoconfig.ts b/tests/osn-tests/src/test_nodeobs_autoconfig.ts deleted file mode 100644 index 335c5fd32..000000000 --- a/tests/osn-tests/src/test_nodeobs_autoconfig.ts +++ /dev/null @@ -1,189 +0,0 @@ -import 'mocha'; -import { expect } from 'chai'; -import * as osn from '../osn'; -import { logInfo, logEmptyLine, logWarning } from '../util/logger'; -import { ETestErrorMsg, GetErrorMessage } from '../util/error_messages'; -import { OBSHandler, IConfigProgress } from '../util/obs_handler'; -import { deleteConfigFiles } from '../util/general'; - -// ============================================================================= -// DEPRECATED — replaced by tests/osn-tests/src/test_osn_autoconfig_v2.ts -// ============================================================================= -// This file exercises the legacy autoconfig contract: -// - obs.startAutoconfig() with no target ids -// - server-side persistence via basic config file (config_set_int / config_set_string) -// - assertions via obs.getSetting('Output', 'VBitrate') etc. -// -// Both halves of that contract are gone after the autoconfig-v2 port: -// 1. obs.startAutoconfig() now requires an explicit osn.IStreaming[] — the -// frontend passes the targets to test (see startAutoconfig in obs_handler.ts); -// the server no longer discovers or persists them. The bandwidth test emits -// 'no_streaming_targets_provided' if the array is empty. -// 2. SaveStreamSettings / SaveSettings no longer write to basic.ini — Phase 2 -// replaced them with applyResults() which mutates live osn objects via -// obs_service_update / obs_encoder_update / obs_set_video_info. The -// obs.getSetting(...) assertions below would never see the autoconfig output -// again because that path simply doesn't exist. -// -// The suite is skipped wholesale rather than deleted so the historical contract -// stays grep-able. Once the legacy nodeobs_autoconfig.cpp ifdef block and dead -// code are removed in Phase 5 cleanup, drop this file too. -// ============================================================================= -const testName = 'nodeobs_autoconfig'; - -describe.skip(testName + ' (DEPRECATED — see test_osn_autoconfig_v2.ts)', function() { - this.timeout(30000) - let obs: OBSHandler; - let hasTestFailed: boolean = false; - - // Initialize OBS process - before(async function() { - logInfo(testName, 'Starting ' + testName + ' tests'); - deleteConfigFiles(); - obs = new OBSHandler(testName, false); - - obs.instantiateUserPool(testName); - - // Reserving user from pool - await obs.reserveUser(); - }); - - // Shutdown OBS process - after(async function() { - - // Releasing user got from pool - await obs.releaseUser(); - - // Closing OBS process - obs.shutdown(); - - if (hasTestFailed === true) { - logInfo(testName, 'One or more test cases failed. Uploading cache'); - await obs.uploadTestCache(); - } - - obs = null; - deleteConfigFiles(); - logInfo(testName, 'Finished ' + testName + ' tests'); - logEmptyLine(); - }); - - afterEach(async function() { - hasTestFailed = (await obs.finalizeRetryableTest(this)) || hasTestFailed; - }); - - it('Run autoconfig', async function() { - if (obs.isDarwin()) { - this.skip(); - } - const start = performance.now(); - let progressInfo: IConfigProgress; - let settingValue: any; - - obs.startAutoconfig([]); - - osn.NodeObs.StartBandwidthTest(); - - progressInfo = await obs.getNextProgressInfo('Bandwidth test'); - - if (progressInfo.event != 'error') { - expect(progressInfo.event).to.equal('stopping_step', GetErrorMessage(ETestErrorMsg.BandwidthTest)); - expect(progressInfo.description).to.equal('bandwidth_test', GetErrorMessage(ETestErrorMsg.BandwidthTest)); - expect(progressInfo.percentage).to.equal(100, GetErrorMessage(ETestErrorMsg.BandwidthTest)); - - osn.NodeObs.StartStreamEncoderTest(); - - progressInfo = await obs.getNextProgressInfo('Stream Encoder test'); - expect(progressInfo.event).to.equal('stopping_step', GetErrorMessage(ETestErrorMsg.StreamEncoderTest)); - expect(progressInfo.description).to.equal('streamingEncoder_test', GetErrorMessage(ETestErrorMsg.StreamEncoderTest)); - expect(progressInfo.percentage).to.equal(100, GetErrorMessage(ETestErrorMsg.StreamEncoderTest)); - - osn.NodeObs.StartRecordingEncoderTest(); - - progressInfo = await obs.getNextProgressInfo('Recording Encoder test'); - expect(progressInfo.event).to.equal('stopping_step', GetErrorMessage(ETestErrorMsg.RecordingEncoderTest)); - expect(progressInfo.description).to.equal('recordingEncoder_test', GetErrorMessage(ETestErrorMsg.RecordingEncoderTest)); - expect(progressInfo.percentage).to.equal(100, GetErrorMessage(ETestErrorMsg.RecordingEncoderTest)); - - osn.NodeObs.StartCheckSettings(); - - progressInfo = await obs.getNextProgressInfo('Check Settings'); - expect(progressInfo.event).to.equal('stopping_step', GetErrorMessage(ETestErrorMsg.CheckSettings)); - expect(progressInfo.description).to.equal('checking_settings', GetErrorMessage(ETestErrorMsg.CheckSettings)); - expect(progressInfo.percentage).to.equal(100, GetErrorMessage(ETestErrorMsg.CheckSettings)); -/* - osn.NodeObs.StartSaveStreamSettings(); - - progressInfo = await obs.getNextProgressInfo('Save Stream Settings'); - expect(progressInfo.event).to.equal('stopping_step', GetErrorMessage(ETestErrorMsg.SaveStreamSettings)); - expect(progressInfo.description).to.equal('saving_service', GetErrorMessage(ETestErrorMsg.SaveStreamSettings)); - expect(progressInfo.percentage).to.equal(100, GetErrorMessage(ETestErrorMsg.SaveStreamSettings)); - - osn.NodeObs.StartSaveSettings(); - - progressInfo = await obs.getNextProgressInfo('Save Settings'); - expect(progressInfo.event).to.equal('stopping_step', GetErrorMessage(ETestErrorMsg.SaveSettingsStep)); - expect(progressInfo.description).to.equal('saving_settings', GetErrorMessage(ETestErrorMsg.SaveSettingsStep)); - expect(progressInfo.percentage).to.equal(100, GetErrorMessage(ETestErrorMsg.SaveSettingsStep)); - - progressInfo = await obs.getNextProgressInfo('Autoconfig done'); - expect(progressInfo.event).to.equal('done', GetErrorMessage(ETestErrorMsg.SaveSettingsStep)); - */ - } else { - logWarning(testName, 'Bandwidth test failed with ' + progressInfo.description + '. Setting default settings'); - - osn.NodeObs.StartSetDefaultSettings(); - - progressInfo = await obs.getNextProgressInfo('Set Default Settings'); - expect(progressInfo.event).to.equal('stopping_step', GetErrorMessage(ETestErrorMsg.SetDefaultSettings)); - expect(progressInfo.description).to.equal('setting_default_settings', GetErrorMessage(ETestErrorMsg.SetDefaultSettings)); - expect(progressInfo.percentage).to.equal(100, GetErrorMessage(ETestErrorMsg.SetDefaultSettings)); - - osn.NodeObs.StartSaveStreamSettings(); - - progressInfo = await obs.getNextProgressInfo('Save Stream Settings'); - expect(progressInfo.event).to.equal('stopping_step', GetErrorMessage(ETestErrorMsg.SaveStreamSettings)); - expect(progressInfo.description).to.equal('saving_service', GetErrorMessage(ETestErrorMsg.SaveStreamSettings)); - expect(progressInfo.percentage).to.equal(100, GetErrorMessage(ETestErrorMsg.SaveStreamSettings)); - - osn.NodeObs.StartSaveSettings(); - - progressInfo = await obs.getNextProgressInfo('Save Settings'); - expect(progressInfo.event).to.equal('stopping_step', GetErrorMessage(ETestErrorMsg.SaveSettingsStep)); - expect(progressInfo.description).to.equal('saving_settings', GetErrorMessage(ETestErrorMsg.SaveSettingsStep)); - expect(progressInfo.percentage).to.equal(100, GetErrorMessage(ETestErrorMsg.SaveSettingsStep)); - - progressInfo = await obs.getNextProgressInfo('Autoconfig done'); - expect(progressInfo.event).to.equal('done', GetErrorMessage(ETestErrorMsg.SaveSettingsStep)); - - // Checking default settings - settingValue = obs.getSetting('Output', 'Mode'); - expect(settingValue).to.equal('Simple', GetErrorMessage(ETestErrorMsg.DefaultOutputMode)); - - settingValue = obs.getSetting('Output', 'VBitrate'); - expect(settingValue).to.equal(4500, GetErrorMessage(ETestErrorMsg.DefaultVBitrate)); - - settingValue = obs.getSetting('Output', 'StreamEncoder'); - expect(settingValue).to.equal('x264', GetErrorMessage(ETestErrorMsg.DefaultStreamEncoder)); - - settingValue = obs.getSetting('Output', 'RecQuality'); - expect(settingValue).to.equal('Small', GetErrorMessage(ETestErrorMsg.DefaultRecQuality)); - - settingValue = obs.getSetting('Advanced', 'DynamicBitrate'); - expect(settingValue).to.equal(false, GetErrorMessage(ETestErrorMsg.DefaultDinamicBitrate)); - - settingValue = obs.getSetting('Video', 'Output'); - expect(settingValue).to.equal('1280x720', GetErrorMessage(ETestErrorMsg.DefaultVideoOutput)); - - settingValue = obs.getSetting('Video', 'FPSType'); - expect(settingValue).to.equal('Common FPS Values', GetErrorMessage(ETestErrorMsg.DefaultFPSType)); - - settingValue = obs.getSetting('Video', 'FPSCommon'); - expect(settingValue).to.equal('30', GetErrorMessage(ETestErrorMsg.DefaultFPSCommon)); - const end = performance.now(); - logInfo(testName, `Elapsed time: ${end - start} milliseconds`); - } - - osn.NodeObs.TerminateAutoConfig(); - }); -}); diff --git a/tests/osn-tests/src/test_osn_auto_optimizer_v1.ts b/tests/osn-tests/src/test_osn_auto_optimizer_v1.ts new file mode 100644 index 000000000..e0d7f31b8 --- /dev/null +++ b/tests/osn-tests/src/test_osn_auto_optimizer_v1.ts @@ -0,0 +1,265 @@ +import 'mocha'; +import { expect } from 'chai'; +import * as osn from '../osn'; +import type { + IAutoConfigCapabilities, + IAutoConfigEvent, + IAutoConfigLegRequest, + IAutoConfigRequest, + IAutoConfigResult, +} from '../../../js/module'; +import * as net from 'net'; +import { OBSHandler } from '../util/obs_handler'; +import { deleteConfigFiles } from '../util/general'; + +const testName = 'osn-auto-optimizer-v1'; +const mockPort = 11937; + +describe(testName, function() { + this.timeout(30000); + + let obs: OBSHandler; + + async function startConnectionSink(port: number) { + let connections = 0; + let bytes = 0; + const server = net.createServer(socket => { + connections++; + socket.on('data', chunk => bytes += chunk.length); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', resolve); + }); + return { + getConnections: () => connections, + getBytes: () => bytes, + close: () => new Promise(resolve => server.close(() => resolve())), + }; + } + + before(function() { + deleteConfigFiles(); + obs = new OBSHandler(testName); + }); + + after(function() { + if (obs) { + obs.shutdown(); + obs = null; + } + deleteConfigFiles(); + }); + + function leg(overrides: Partial = {}): IAutoConfigLegRequest { + return { + legId: 'primary', + display: 'horizontal', + destinations: [{ platform: 'custom' }], + current: { + width: 1280, + height: 720, + fpsNum: 30, + fpsDen: 1, + bitrateKbps: 2500, + encoderId: 'obs_x264', + codec: 'h264', + preset: 'veryfast', + }, + ...overrides, + }; + } + + async function run(request: IAutoConfigRequest): Promise<{ + sessionId: string; + events: IAutoConfigEvent[]; + result: IAutoConfigResult; + }> { + const events: IAutoConfigEvent[] = []; + let sessionId = ''; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (sessionId) { + try { osn.NodeObs.CancelAutoConfigSession(sessionId); } catch (_) { /* best effort */ } + try { osn.NodeObs.CloseAutoConfigSession(sessionId); } catch (_) { /* best effort */ } + } + reject(new Error('Auto Optimizer session timed out')); + }, 15000); + + const onEvent = (event: IAutoConfigEvent) => { + events.push(event); + if (event.type !== 'complete' && event.type !== 'cancelled') return; + + try { + const raw = osn.NodeObs.GetAutoConfigResult(sessionId); + const result = JSON.parse(raw) as IAutoConfigResult; + osn.NodeObs.CloseAutoConfigSession(sessionId); + clearTimeout(timeout); + resolve({ sessionId, events, result }); + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }; + + try { + sessionId = osn.NodeObs.CreateAutoConfigSession(JSON.stringify(request), onEvent); + osn.NodeObs.StartAutoConfigSession(sessionId); + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }); + } + + it('advertises the versioned, Desktop-owned apply contract', function() { + const capabilities = JSON.parse(osn.NodeObs.GetAutoConfigCapabilities()) as IAutoConfigCapabilities; + expect(capabilities).to.deep.equal({ + apiVersion: 2, + resultSchemaVersion: 1, + previewApplySplit: true, + awaitableCancel: true, + perUploadLegResults: true, + desktopOwnedApply: true, + bandwidthModes: ['twitch-standard-active', 'estimate'], + }); + }); + + it('never dials an ineligible custom RTMP active-probe request', async function() { + const mock = await startConnectionSink(mockPort); + const secret = 'must-not-appear-in-result'; + try { + const response = await run({ + schemaVersion: 1, + topology: 'custom-rtmp', + legs: [leg()], + activeProbe: { + kind: 'twitch-standard-v1', + legId: 'primary', + serviceName: 'Twitch', + server: `rtmp://127.0.0.1:${mockPort}/live`, + streamKey: secret, + }, + }); + + expect(mock.getConnections()).to.equal(0); + expect(mock.getBytes()).to.equal(0); + expect(response.result.status).to.equal('complete'); + expect(response.result.legs[0].measurement.mode).to.equal('estimated'); + expect(response.result.legs[0].measurement.reason).to.equal('custom_rtmp'); + expect(response.result.legs[0].recommendation.bitrateKbps).to.equal(2500); + expect(JSON.stringify(response.result)).not.to.contain(secret); + expect(JSON.stringify(response.events)).not.to.contain(secret); + expect(response.events.some(event => event.code === 'active_probe_not_eligible')).to.equal(true); + } finally { + await mock.close(); + } + }); + + it('clamps estimate-only results to bundled platform caps without raising current bitrate', async function() { + const twitch = await run({ + schemaVersion: 1, + topology: 'direct-single', + legs: [leg({ + destinations: [{ platform: 'twitch' }], + current: { ...leg().current, bitrateKbps: 8000 }, + estimateReason: 'probe_disabled', + })], + }); + expect(twitch.result.legs[0].measurement.mode).to.equal('estimated'); + expect(twitch.result.legs[0].recommendation.bitrateKbps).to.equal(6000); + expect(twitch.result.legs[0].limits.maxBitrateKbps).to.equal(6000); + + const alreadyConservative = await run({ + schemaVersion: 1, + topology: 'direct-single', + legs: [leg({ + destinations: [{ platform: 'twitch' }], + current: { ...leg().current, bitrateKbps: 2500 }, + estimateReason: 'probe_disabled', + })], + }); + expect(alreadyConservative.result.legs[0].recommendation.bitrateKbps).to.equal(2500); + }); + + it('uses the strictest destination/request cap and replaces an unavailable encoder', async function() { + const response = await run({ + schemaVersion: 1, + topology: 'cloud-multistream', + legs: [leg({ + destinations: [{ platform: 'youtube' }, { platform: 'twitch' }], + current: { + ...leg().current, + bitrateKbps: 9000, + encoderId: 'definitely-not-a-real-encoder', + }, + limits: { maxBitrateKbps: 1800 }, + estimateReason: 'cloud_multistream', + })], + }); + + expect(response.result.legs[0].recommendation.bitrateKbps).to.equal(1800); + expect(response.result.legs[0].recommendation.encoderId).not.to.equal('definitely-not-a-real-encoder'); + expect(response.result.legs[0].measurement.confidence).to.equal('medium'); + }); + + it('cancels a prepared session and makes cleanup observable before returning', async function() { + const events: IAutoConfigEvent[] = []; + const sessionId = osn.NodeObs.CreateAutoConfigSession(JSON.stringify({ + schemaVersion: 1, + topology: 'custom-rtmp', + legs: [leg()], + } as IAutoConfigRequest), (event: IAutoConfigEvent) => events.push(event)); + + osn.NodeObs.CancelAutoConfigSession(sessionId); + const result = JSON.parse(osn.NodeObs.GetAutoConfigResult(sessionId)) as IAutoConfigResult; + expect(result.status).to.equal('cancelled'); + expect(result.error.code).to.equal('cancelled'); + osn.NodeObs.CloseAutoConfigSession(sessionId); + }); + + it('cancels a running session only after its scratch worker has cleaned up', async function() { + const events: IAutoConfigEvent[] = []; + const sessionId = osn.NodeObs.CreateAutoConfigSession(JSON.stringify({ + schemaVersion: 1, + topology: 'custom-rtmp', + legs: [leg()], + } as IAutoConfigRequest), (event: IAutoConfigEvent) => events.push(event)); + + osn.NodeObs.StartAutoConfigSession(sessionId); + // Let the worker enter the hardware phase so this covers cancellation + // of an executing scratch workload rather than a prepared session. + await new Promise(resolve => setTimeout(resolve, 100)); + + osn.NodeObs.CancelAutoConfigSession(sessionId); + const result = JSON.parse(osn.NodeObs.GetAutoConfigResult(sessionId)) as IAutoConfigResult; + expect(result.status).to.equal('cancelled'); + expect(result.error.code).to.equal('cancelled'); + osn.NodeObs.CloseAutoConfigSession(sessionId); + }); + + it('rejects malformed versioned requests before creating a session', function() { + expect(() => osn.NodeObs.CreateAutoConfigSession(JSON.stringify({ + schemaVersion: 999, + topology: 'direct-single', + legs: [leg()], + }), () => undefined)).to.throw('unsupported_autoconfig_schema'); + }); + + it('disconnects cleanly while a session worker is running', async function() { + const sessionId = osn.NodeObs.CreateAutoConfigSession(JSON.stringify({ + schemaVersion: 1, + topology: 'custom-rtmp', + legs: [leg()], + } as IAutoConfigRequest), () => undefined); + + osn.NodeObs.StartAutoConfigSession(sessionId); + await new Promise(resolve => setTimeout(resolve, 100)); + + const startedAt = Date.now(); + obs.shutdown(); + obs = null; + expect(Date.now() - startedAt).to.be.lessThan(15000); + }); +}); diff --git a/tests/osn-tests/src/test_osn_autoconfig_v2.ts b/tests/osn-tests/src/test_osn_autoconfig_v2.ts deleted file mode 100644 index d3aea55cd..000000000 --- a/tests/osn-tests/src/test_osn_autoconfig_v2.ts +++ /dev/null @@ -1,433 +0,0 @@ -import 'mocha'; -import { expect } from 'chai'; -import * as osn from '../osn'; -import { logInfo, logEmptyLine } from '../util/logger'; -import { OBSHandler, IConfigProgress } from '../util/obs_handler'; -import { deleteConfigFiles, sleep } from '../util/general'; -import { startMockRtmp } from '../util/mock_rtmp'; -import { randomUUID } from 'crypto'; - -const testName = 'osn-autoconfig'; - -const MOCK_RTMP_PORT = 11935; -const MOCK_RTMP_PORT2 = 11936; - -describe(testName, function() { - this.timeout(120000); // bandwidth tests + apply phase - - let obs: OBSHandler; - let hasTestFailed: boolean = false; - let videoContext: osn.IVideo = null; - - let sceneName: string; - let sourceName: string; - - before(async function() { - logInfo(testName, 'Starting ' + testName + ' tests'); - deleteConfigFiles(); - obs = new OBSHandler(testName); - obs.connectOutputSignals(); - - videoContext = osn.VideoFactory.create(); - videoContext.video = { - fpsNum: 30, - fpsDen: 1, - baseWidth: 1280, - baseHeight: 720, - outputWidth: 1280, - outputHeight: 720, - outputFormat: osn.EVideoFormat.NV12, - colorspace: osn.EColorSpace.CS709, - range: osn.ERangeType.Full, - scaleType: osn.EScaleType.Lanczos, - fpsType: osn.EFPSType.Fractional, - }; - }); - - after(async function() { - if (videoContext) videoContext.destroy(); - - obs.shutdown(); - - if (hasTestFailed === true) { - logInfo(testName, 'One or more test cases failed. Uploading cache'); - await obs.uploadTestCache(); - } - - obs = null; - deleteConfigFiles(); - logInfo(testName, 'Finished ' + testName + ' tests'); - logEmptyLine(); - }); - - beforeEach(function() { - // Each case gets its own scene/source pair so failed cleanup in one case - // can't poison the next. - sceneName = 'scene_' + randomUUID(); - sourceName = 'color_source_' + randomUUID(); - const scene = osn.SceneFactory.create(sceneName); - const source = osn.InputFactory.create('color_source', sourceName); - scene.add(source); - osn.Global.setOutputSource(0, scene); - }); - - afterEach(function() { - const scene = osn.SceneFactory.fromName(sceneName); - if (scene) scene.release(); - if (this.currentTest.state === 'failed') hasTestFailed = true; - }); - - // Build a SimpleStreaming target wired to the mock RTMP server. Returned objects - // must be cleaned up by the caller via cleanupStreamingTarget(). - function buildStreamingTarget(label: string, server: string) { - const videoEncoder = osn.VideoEncoderFactory.create('obs_x264', `enc-${label}`); - videoEncoder.update({ bitrate: 2500, rate_control: 'CBR', preset: 'veryfast', keyint_sec: 2 }); - const audioEncoder = osn.AudioEncoderFactory.create('ffmpeg_aac', `aenc-${label}`); - const service = osn.ServiceFactory.create('rtmp_common', `svc-${label}`); - service.update({ service: 'Custom', server, key: `key-${label}` }); - - const stream = osn.SimpleStreamingFactory.create(); - stream.videoEncoder = videoEncoder; - stream.audioEncoder = audioEncoder; - stream.service = service; - stream.video = videoContext; - stream.delay = osn.DelayFactory.create(); - stream.reconnect = osn.ReconnectFactory.create(); - stream.network = osn.NetworkFactory.create(); - stream.enforceServiceBitrate = false; - stream.signalHandler = (signal) => obs.signals.push(signal); - return { stream, service, videoEncoder, audioEncoder }; - } - - function cleanupStreamingTarget(t: ReturnType) { - osn.SimpleStreamingFactory.destroy(t.stream); - osn.ServiceFactory.destroy(t.service); - t.videoEncoder.release(); - t.audioEncoder.release(); - } - - // Drains autoconfig events until the predicate returns true, an error event - // arrives, or the deadline elapses. Returns the full list of events seen. - async function drainUntil(stop: (ev: IConfigProgress) => boolean): Promise { - const seen: IConfigProgress[] = []; - const deadline = Date.now() + 60000; - while (Date.now() < deadline) { - const ev = await obs.getNextProgressInfo('autoconfig'); - seen.push(ev); - if (ev.event === 'error' || stop(ev)) return seen; - } - throw new Error('autoconfig drain timeout'); - } - const stageDone = (description: string) => - (ev: IConfigProgress) => ev.event === 'stopping_step' && ev.description === description; - const isDone = (ev: IConfigProgress) => ev.event === 'done'; - - // Pretty-print every resource_usage event collected during a phase. p50 is the - // typical value, p95 is the sustained ceiling (single-sample spikes from - // unrelated OS noise are dropped). Mirrors the JSON shape built by - // resourceWindowToJson() in nodeobs_autoconfig.cpp. - function logResourceEvents(events: IConfigProgress[]) { - const fmt = (n: number, d = 1) => (typeof n === 'number' ? n.toFixed(d) : 'n/a'); - for (const ev of events.filter(e => e.event === 'resource_usage')) { - try { - const p = JSON.parse(ev.payload || '{}'); - const cpu = `cpu p50/p95=${fmt(p.cpuPct?.p50)}/${fmt(p.cpuPct?.p95)}%`; - const ram = `ram p50/p95=${fmt(p.procRamMB?.p50, 0)}/${fmt(p.procRamMB?.p95, 0)}MB`; - const gpu = p.gpu?.available - ? `vram p50/p95=${p.gpu.vramUsedMB.p50}/${p.gpu.vramUsedMB.p95}MB (budget ${p.gpu.vramBudgetMB}MB)` - : 'gpu=n/a'; - logInfo(testName, `[resource ${p.phase}] samples=${p.sampleCount} dur=${p.durationMs}ms ${cpu} ${ram} ${gpu}`); - } catch (e) { - logInfo(testName, `resource_usage parse error: ${(e as Error).message} payload=${ev.payload}`); - } - } - } - - // Pull GetAutoConfigSummary().resourceUsage and log a one-line digest per window. - // Useful to confirm the summary IPC matches what came over the event stream. - function logResourceSummary() { - try { - const raw = osn.NodeObs.GetAutoConfigSummary() as string; - if (!raw) return; - const parsed = JSON.parse(raw); - const windows = parsed.resourceUsage || []; - logInfo(testName, `summary.resourceUsage: ${windows.length} window(s)`); - for (const w of windows) { - logInfo(testName, ` ${w.phase}: samples=${w.sampleCount} dur=${w.durationMs}ms cpuP95=${w.cpuPct?.p95?.toFixed?.(1)}% ramP95=${w.procRamMB?.p95?.toFixed?.(0)}MB`); - } - } catch (e) { - logInfo(testName, `summary parse error: ${(e as Error).message}`); - } - } - - it('Bandwidth test contacts the mock RTMP server', async function() { - if (obs.isDarwin()) this.skip(); - - const mockRtmp = await startMockRtmp(MOCK_RTMP_PORT); - logInfo(testName, `Mock RTMP listening on 127.0.0.1:${MOCK_RTMP_PORT}`); - - const t = buildStreamingTarget('bw', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); - try { - obs.startAutoconfig([t.stream]); - osn.NodeObs.StartBandwidthTest(); - - const events = await drainUntil(stageDone('bandwidth_test')); - logInfo(testName, `bandwidth events: ${JSON.stringify(events.filter(e => e.event !== 'resource_usage'))} mock conns=${mockRtmp.getConnections()} bytes=${mockRtmp.getBytes()}`); - logResourceEvents(events); - logResourceSummary(); - - expect(mockRtmp.getConnections()).to.be.greaterThan(0, - 'Mock RTMP saw no connection — autoconfig did not dial the configured server'); - - const errorEvent = events.find((e) => e.event === 'error'); - expect(errorEvent).to.equal(undefined, - `Bandwidth test failed with: ${errorEvent?.description}`); - } finally { - cleanupStreamingTarget(t); - await mockRtmp.close(); - } - }); - - it('Apply phase lands defaults on live osn objects', async function() { - // SetDefaultSettings populates runContext with hardcoded, known values - // (idealResolutionCX=1280, CY=720, FPSNum=30, idealBitrate=4500). SaveSettings - // then runs applyResults() which discovers all registered streaming targets - // and pushes those values into them. After 'done', the live objects' Get* - // methods should report the applied values. - - // Use a fresh video context here. The shared `videoContext` from before() - // gets touched by other tests' streaming pipelines and ends up with libobs - // applying its own canonicalisation to fps_num — easier to start clean. - const localVideo = osn.VideoFactory.create(); - localVideo.video = { - fpsNum: 60, fpsDen: 1, - baseWidth: 1920, baseHeight: 1080, - outputWidth: 1920, outputHeight: 1080, - outputFormat: osn.EVideoFormat.NV12, - colorspace: osn.EColorSpace.CS709, - range: osn.ERangeType.Full, - scaleType: osn.EScaleType.Lanczos, - fpsType: osn.EFPSType.Fractional, - }; - - const t = buildStreamingTarget('apply', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); - // Re-point this stream's video at the local context. - t.stream.video = localVideo; - try { - const before = localVideo.video; - logInfo(testName, `pre-apply video: ${JSON.stringify(before)}`); - - obs.startAutoconfig([t.stream]); - - osn.NodeObs.StartSetDefaultSettings(); - await drainUntil(stageDone('setting_default_settings')); - - osn.NodeObs.StartSaveSettings(); - const events = await drainUntil(isDone); - logInfo(testName, `apply events: ${JSON.stringify(events)}`); - const terminal = events[events.length - 1]; - expect(terminal.event).to.equal('done', `Expected terminal 'done', got '${terminal.event}/${terminal.description}'`); - - // SetDefaultSettings sets idealBitrate=4500 — applyResults forwards that - // to the videoEncoder via obs_encoder_update, capped by EstimateUpperBitrate - // for the chosen resolution. For 1280x720@30 the cap is ~3000 kbps, so the - // applied value will be in (initial=2500, idealBitrate=4500]. - const appliedBitrate = t.videoEncoder.settings['bitrate'] as number; - expect(appliedBitrate).to.be.greaterThan(2500, `bitrate did not change from initial 2500: got ${appliedBitrate}`); - expect(appliedBitrate).to.be.lessThanOrEqual(4500, `bitrate exceeded idealBitrate 4500: got ${appliedBitrate}`); - - // SetDefaultSettings sets idealResolution 1280x720 — applyResults forwards - // via obs_set_video_info on the videoId canvas. - // - // Note on FPS: libobs's obs_set_video_info only changes output_width / - // output_height at runtime. fps_num is locked once the video pipeline is - // alive and only updates on a destroy+recreate of the video context. We - // therefore assert width/height landed but not fpsNum — the frontend has - // to drop and recreate the canvas to take a new framerate. - const v = localVideo.video; - logInfo(testName, `post-apply video: ${JSON.stringify(v)}`); - expect(v.outputWidth).to.equal(1280, `expected outputWidth 1280, got ${v.outputWidth}`); - expect(v.outputHeight).to.equal(720, `expected outputHeight 720, got ${v.outputHeight}`); - } finally { - cleanupStreamingTarget(t); - localVideo.destroy(); - } - }); - - it('Autoconfig with no streaming target reports an error event', async function() { - // Empty target list — server should reject with no_streaming_targets_provided - // during bandwidth test. - obs.startAutoconfig([]); - osn.NodeObs.StartBandwidthTest(); - - const events = await drainUntil(stageDone('bandwidth_test')); - // Need at least one error event; description identifies the missing target. - const errorEvent = events.find((e) => e.event === 'error'); - expect(errorEvent).to.not.equal(undefined, 'Expected an error event'); - expect(errorEvent.description).to.equal('no_streaming_targets_provided'); - }); - - it('TerminateAutoConfig mid-bandwidth-test leaves live values untouched', async function() { - if (obs.isDarwin()) this.skip(); - - const mockRtmp = await startMockRtmp(MOCK_RTMP_PORT); - const t = buildStreamingTarget('cancel', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); - const beforeBitrate = t.videoEncoder.settings['bitrate'] as number; - const beforeServer = t.service.settings['server'] as string; - - try { - obs.startAutoconfig([t.stream]); - osn.NodeObs.StartBandwidthTest(); - - await sleep(500); - osn.NodeObs.TerminateAutoConfig(); - - // TerminateAutoConfig sets the cancel flag and kills the client-side - // polling worker. The bandwidth thread may still be winding down - // asynchronously — give it a moment, then verify values are untouched. - // We intentionally do NOT drainUntil() here because the worker that - // delivers events has already been stopped. - await sleep(1000); - - expect(t.videoEncoder.settings['bitrate']).to.equal(beforeBitrate, 'Bitrate must not change on cancel'); - expect(t.service.settings['server']).to.equal(beforeServer, 'Server URL must not change on cancel'); - } finally { - cleanupStreamingTarget(t); - await mockRtmp.close(); - } - }); - - // ---- Encoder-phase tests (resource sampling exercise) ---- - - it('Stream encoder test surfaces resource_usage', async function() { - if (obs.isDarwin()) this.skip(); - - const t = buildStreamingTarget('senc', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); - try { - obs.startAutoconfig([t.stream]); - - osn.NodeObs.StartStreamEncoderTest(); - const events = await drainUntil(stageDone('runContext.streamingEncoder_test')); - logInfo(testName, `stream-encoder events: ${JSON.stringify(events.filter(e => e.event !== 'resource_usage'))}`); - logResourceEvents(events); - logResourceSummary(); - - const errorEvent = events.find((e) => e.event === 'error'); - expect(errorEvent).to.equal(undefined, - `Stream encoder test failed with: ${errorEvent?.description}`); - - const resEvents = events.filter(e => e.event === 'resource_usage'); - expect(resEvents.length).to.be.greaterThan(0, 'expected at least one resource_usage event'); - for (const r of resEvents) { - const p = JSON.parse(r.payload || '{}'); - expect(p.sampleCount).to.be.greaterThan(0, `resource window for ${p.phase} had no samples`); - } - } finally { - cleanupStreamingTarget(t); - } - }); - - it('Recording encoder test surfaces resource_usage', async function() { - if (obs.isDarwin()) this.skip(); - - const t = buildStreamingTarget('renc', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); - try { - obs.startAutoconfig([t.stream]); - - osn.NodeObs.StartRecordingEncoderTest(); - const events = await drainUntil(stageDone('runContext.recordingEncoder_test')); - logInfo(testName, `recording-encoder events: ${JSON.stringify(events.filter(e => e.event !== 'resource_usage'))}`); - logResourceEvents(events); - logResourceSummary(); - - const errorEvent = events.find((e) => e.event === 'error'); - expect(errorEvent).to.equal(undefined, - `Recording encoder test failed with: ${errorEvent?.description}`); - - const resEvents = events.filter(e => e.event === 'resource_usage'); - expect(resEvents.length).to.be.greaterThan(0, 'expected at least one resource_usage event'); - } finally { - cleanupStreamingTarget(t); - } - }); - - // ---- Dual-target (Dual Output) tests ---- - - it('Dual-target bandwidth test contacts both mock RTMP servers', async function() { - if (obs.isDarwin()) this.skip(); - - const mockRtmp1 = await startMockRtmp(MOCK_RTMP_PORT); - const mockRtmp2 = await startMockRtmp(MOCK_RTMP_PORT2); - logInfo(testName, `Mock RTMP listening on ports ${MOCK_RTMP_PORT} and ${MOCK_RTMP_PORT2}`); - - const t1 = buildStreamingTarget('dual-bw1', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); - const t2 = buildStreamingTarget('dual-bw2', `rtmp://127.0.0.1:${MOCK_RTMP_PORT2}/live`); - try { - obs.startAutoconfig([t1.stream, t2.stream]); - osn.NodeObs.StartBandwidthTest(); - - const events = await drainUntil(stageDone('bandwidth_test')); - logInfo(testName, `dual-bw events: ${JSON.stringify(events.filter(e => e.event !== 'resource_usage'))} mock1 conns=${mockRtmp1.getConnections()} mock2 conns=${mockRtmp2.getConnections()}`); - logResourceEvents(events); - logResourceSummary(); - - expect(mockRtmp1.getConnections()).to.be.greaterThan(0, - 'Mock RTMP #1 saw no connection — primary target was not tested'); - expect(mockRtmp2.getConnections()).to.be.greaterThan(0, - 'Mock RTMP #2 saw no connection — secondary target was not tested'); - } finally { - cleanupStreamingTarget(t1); - cleanupStreamingTarget(t2); - await mockRtmp1.close(); - await mockRtmp2.close(); - } - }); - - it('Apply phase with dual targets applies per-target values', async function() { - const localVideo = osn.VideoFactory.create(); - localVideo.video = { - fpsNum: 60, fpsDen: 1, - baseWidth: 1920, baseHeight: 1080, - outputWidth: 1920, outputHeight: 1080, - outputFormat: osn.EVideoFormat.NV12, - colorspace: osn.EColorSpace.CS709, - range: osn.ERangeType.Full, - scaleType: osn.EScaleType.Lanczos, - fpsType: osn.EFPSType.Fractional, - }; - - const t1 = buildStreamingTarget('dual-apply1', `rtmp://127.0.0.1:${MOCK_RTMP_PORT}/live`); - const t2 = buildStreamingTarget('dual-apply2', `rtmp://127.0.0.1:${MOCK_RTMP_PORT2}/live`); - t1.stream.video = localVideo; - t2.stream.video = localVideo; - - try { - obs.startAutoconfig([t1.stream, t2.stream]); - - osn.NodeObs.StartSetDefaultSettings(); - await drainUntil(stageDone('setting_default_settings')); - - osn.NodeObs.StartSaveSettings(); - const events = await drainUntil(isDone); - logInfo(testName, `dual-apply events: ${JSON.stringify(events)}`); - - const terminal = events[events.length - 1]; - expect(terminal.event).to.equal('done', `Expected terminal 'done', got '${terminal.event}/${terminal.description}'`); - - // Both targets should have received a bitrate update from applyResults. - const br1 = t1.videoEncoder.settings['bitrate'] as number; - const br2 = t2.videoEncoder.settings['bitrate'] as number; - expect(br1).to.be.greaterThan(2500, `target1 bitrate not updated: got ${br1}`); - expect(br2).to.be.greaterThan(2500, `target2 bitrate not updated: got ${br2}`); - - // Video canvas should have been updated too. - const v = localVideo.video; - expect(v.outputWidth).to.equal(1280, `expected outputWidth 1280, got ${v.outputWidth}`); - expect(v.outputHeight).to.equal(720, `expected outputHeight 720, got ${v.outputHeight}`); - } finally { - cleanupStreamingTarget(t1); - cleanupStreamingTarget(t2); - localVideo.destroy(); - } - }); -}); diff --git a/tests/osn-tests/util/error_messages.ts b/tests/osn-tests/util/error_messages.ts index 76c26e55e..68c3660a4 100644 --- a/tests/osn-tests/util/error_messages.ts +++ b/tests/osn-tests/util/error_messages.ts @@ -12,22 +12,6 @@ export const enum ETestErrorMsg { CoreAudioInputHotkeys = 'Core Audio Input hotkey container is wrong', CoreAudioOutputHotkeys = 'Core Audio Output hotkey container is wrong', - // nodeobs_autoconfig - BandwidthTest = 'Bandwidth test', - StreamEncoderTest = 'Stream encoder test', - RecordingEncoderTest = 'Recording encoder test', - CheckSettings = 'Check settings', - SaveStreamSettings = 'Save stream settings', - SaveSettingsStep = 'Save settings', - SetDefaultSettings = 'Set default settings', - DefaultOutputMode = 'Applied default settings does not have the expected value for output mode', - DefaultVBitrate = 'Applied default settings does not have the expected value for vbitrate', - DefaultStreamEncoder = 'Applied default settings does not have the expected value for stream encoder', - DefaultRecQuality = 'Applied default settings does not have the expected value for rec quality', - DefaultDinamicBitrate = 'Applied default settings does not have the expected value for dinamic bitrate', - DefaultVideoOutput = 'Applied default settings does not have the expected value for video output', - DefaultFPSType = 'Applied default settings does not have the expected value for fps type', - DefaultFPSCommon = 'Applied default settings does not have the expected value for fps common', // nodeobs_service StreamOutput = 'Stream output', RecordingOutput = 'Recording output', @@ -230,4 +214,4 @@ export function GetErrorMessage(message: string, value1?: string, value2?: strin }); return errorMessage; -} \ No newline at end of file +} diff --git a/tests/osn-tests/util/mock_rtmp.ts b/tests/osn-tests/util/mock_rtmp.ts deleted file mode 100644 index 6ef561d49..000000000 --- a/tests/osn-tests/util/mock_rtmp.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Mock RTMP server for autoconfig bandwidth tests, backed by node-media-server. -// -// node-media-server handles the full RTMP protocol (handshake, AMF -// connect/createStream/publish) so libOBS transitions into "publishing" state -// and produces real obs_output_get_total_bytes() values. -// -// Usage: -// const mock = await startMockRtmp(11935); -// // ... run autoconfig pointed at rtmp://127.0.0.1:11935/live ... -// expect(mock.getConnections()).to.be.greaterThan(0); -// await mock.close(); - -import * as net from 'net'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const NodeMediaServer = require('node-media-server'); - -export interface IMockRtmp { - port: number; - getBytes: () => number; - getConnections: () => number; - close: () => Promise; -} - -function waitForPort(port: number, timeoutMs: number = 10000): Promise { - const start = Date.now(); - return new Promise((resolve, reject) => { - function tryConnect() { - if (Date.now() - start > timeoutMs) { - reject(new Error(`Timed out waiting for port ${port} to open`)); - return; - } - const sock = new net.Socket(); - sock.once('connect', () => { - sock.destroy(); - resolve(); - }); - sock.once('error', () => { - sock.destroy(); - setTimeout(tryConnect, 50); - }); - sock.connect(port, '127.0.0.1'); - } - tryConnect(); - }); -} - -export async function startMockRtmp(port: number): Promise { - let connections = 0; - let totalBytes = 0; - - const nms = new NodeMediaServer({ - logType: 0, - rtmp: { - port, - chunk_size: 60000, - gop_cache: false, - ping: 0, - ping_timeout: 60, - }, - }); - - nms.on('postPublish', () => { - connections++; - }); - - nms.on('postConnect', (_id: string, args: any) => { - const session = nms.getSession(_id); - if (session && session.socket) { - session.socket.on('data', (chunk: Buffer) => { - totalBytes += chunk.length; - }); - } - }); - - nms.run(); - - // Wait until the RTMP port is actually accepting connections. - await waitForPort(port); - - return { - port, - getBytes: () => totalBytes, - getConnections: () => connections, - close: () => - new Promise((resolve) => { - nms.stop(); - resolve(); - }), - }; -} diff --git a/tests/osn-tests/util/obs_handler.ts b/tests/osn-tests/util/obs_handler.ts index 7b86067d9..7d710f6aa 100644 --- a/tests/osn-tests/util/obs_handler.ts +++ b/tests/osn-tests/util/obs_handler.ts @@ -34,14 +34,6 @@ export interface IOBSOutputSignalInfo { service: string; } -export interface IConfigProgress { - event: TConfigEvent; - description: string; - percentage?: number; - continent?: string; - payload?: string; -} - export interface IVec2 { x: number; y: number; @@ -67,18 +59,6 @@ export type TOBSHotkey = { HotkeyId: number; }; -export type TConfigEvent = - | 'starting_step' - | 'progress' - | 'stopping_step' - | 'error' - | 'done' - | 'bandwidth_result' - | 'selection_decision' - | 'video_decision' - | 'encoder_detection' - | 'resource_usage'; - // OBSHandler class export class OBSHandler { private path = require('path'); @@ -97,7 +77,6 @@ export class OBSHandler { private hasUserFromPool: boolean = false; private osnTestName: string; signals = new WaitQueue(); - private progress = new WaitQueue(); inputTypes: string[]; filterTypes: string[]; transitionTypes: string[]; @@ -491,31 +470,6 @@ export class OBSHandler { throw new Error(timeoutMessage); } - startAutoconfig(streamings: osn.IStreaming[]) { - // Drop any progress events left over from a prior run so the next drain - // sees only this run's events. - this.progress = new WaitQueue(); - - osn.NodeObs.InitializeAutoConfig(streamings, (progressInfo: IConfigProgress) => { - if (progressInfo.event === 'stopping_step' || progressInfo.event === 'done' - || progressInfo.event === 'error' || (progressInfo.event as string) === 'applied' - || progressInfo.event === 'resource_usage') { - this.progress.push(progressInfo); - } - }); - } - - getNextProgressInfo(autoconfigStep: string): Promise { - return new Promise((resolve, reject) => { - this.progress.shift().then( - function (progressInfo) { - resolve(progressInfo) - } - ); - setTimeout(() => reject(new Error(autoconfigStep + ' step timeout')), 50000); - }); - } - createDefaultVideoContext() { logInfo(this.osnTestName, 'createDefaultVideoContext called'); this.defaultVideoContext = osn.VideoFactory.create(); diff --git a/yarn.lock b/yarn.lock index caec67d7f..b3824dc18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1388,7 +1388,6 @@ __metadata: "@types/chai-subset": "npm:^1.3.5" "@types/mocha": "npm:^10.0.0" "@types/node": "npm:^18.0.0" - "@types/node-media-server": "npm:^2" archiver: "npm:^7.0.0" chai: "npm:^4.2.0" chai-subset: "npm:^1.6.0" @@ -1400,7 +1399,6 @@ __metadata: mocha: "npm:^11.0.0" mocha-junit-reporter: "npm:^1.22.0" node-addon-api: "npm:^7.1.1" - node-media-server: "npm:2.7.2" ts-node: "npm:^7.0.1" typedoc: "npm:^0.28.0" typedoc-plugin-markdown: "npm:^4.0.0" @@ -1479,13 +1477,6 @@ __metadata: languageName: node linkType: hard -"@types/node-media-server@npm:^2": - version: 2.3.7 - resolution: "@types/node-media-server@npm:2.3.7" - checksum: 10c0/666ec4b886ef3d1caeb0c463842a5176c6cd2937ec9a2854cf0b6e205b4307e6a149ec75c215421ffabf81eac0071e72d1848f06dc4271422f8cd2f1bd394a2c - languageName: node - linkType: hard - "@types/node@npm:*": version: 25.6.0 resolution: "@types/node@npm:25.6.0" @@ -1554,16 +1545,6 @@ __metadata: languageName: node linkType: hard -"accepts@npm:~1.3.8": - version: 1.3.8 - resolution: "accepts@npm:1.3.8" - dependencies: - mime-types: "npm:~2.1.34" - negotiator: "npm:0.6.3" - checksum: 10c0/3a35c5f5586cfb9a21163ca47a5f77ac34fa8ceb5d17d2fa2c0d81f41cbd7f8c6fa52c77e2c039acc0f4d09e71abdc51144246900f6bef5e3c4b333f77d89362 - languageName: node - linkType: hard - "ansi-colors@npm:4.1.1": version: 4.1.1 resolution: "ansi-colors@npm:4.1.1" @@ -1662,13 +1643,6 @@ __metadata: languageName: node linkType: hard -"array-flatten@npm:1.1.1": - version: 1.1.1 - resolution: "array-flatten@npm:1.1.1" - checksum: 10c0/806966c8abb2f858b08f5324d9d18d7737480610f3bd5d3498aaae6eb5efdc501a884ba019c9b4a8f02ff67002058749d05548fd42fa8643f02c9c7f22198b91 - languageName: node - linkType: hard - "arrify@npm:^1.0.0": version: 1.0.1 resolution: "arrify@npm:1.0.1" @@ -1683,20 +1657,6 @@ __metadata: languageName: node linkType: hard -"async-function@npm:^1.0.0": - version: 1.0.0 - resolution: "async-function@npm:1.0.0" - checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73 - languageName: node - linkType: hard - -"async-generator-function@npm:^1.0.0": - version: 1.0.0 - resolution: "async-generator-function@npm:1.0.0" - checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186 - languageName: node - linkType: hard - "async@npm:^3.2.4": version: 3.2.6 resolution: "async@npm:3.2.6" @@ -1813,15 +1773,6 @@ __metadata: languageName: node linkType: hard -"basic-auth-connect@npm:^1.1.0": - version: 1.1.0 - resolution: "basic-auth-connect@npm:1.1.0" - dependencies: - tsscmp: "npm:^1.0.6" - checksum: 10c0/bd229e1339d9025c6cd08371860160072c97eb0323b79330daae51ef4d7fe9768b46c730779516d835ceb3f6295bc1fd73594100cc295dc2d0405724cdc3a7e6 - languageName: node - linkType: hard - "binary-extensions@npm:^2.0.0": version: 2.3.0 resolution: "binary-extensions@npm:2.3.0" @@ -1829,26 +1780,6 @@ __metadata: languageName: node linkType: hard -"body-parser@npm:~1.20.3": - version: 1.20.5 - resolution: "body-parser@npm:1.20.5" - dependencies: - bytes: "npm:~3.1.2" - content-type: "npm:~1.0.5" - debug: "npm:2.6.9" - depd: "npm:2.0.0" - destroy: "npm:~1.2.0" - http-errors: "npm:~2.0.1" - iconv-lite: "npm:~0.4.24" - on-finished: "npm:~2.4.1" - qs: "npm:~6.15.1" - raw-body: "npm:~2.5.3" - type-is: "npm:~1.6.18" - unpipe: "npm:~1.0.0" - checksum: 10c0/ad777ca5e4711eae253c93f50fdc4608c60b76a9710d79e5e5b84581c76691e6ad21ecc9158986d9ea2b365df73e403ca33c27a8bccc1a7cfc2ccc248548118d - languageName: node - linkType: hard - "boolean@npm:^3.0.1": version: 3.2.0 resolution: "boolean@npm:3.2.0" @@ -1938,13 +1869,6 @@ __metadata: languageName: node linkType: hard -"bytes@npm:~3.1.2": - version: 3.1.2 - resolution: "bytes@npm:3.1.2" - checksum: 10c0/76d1c43cbd602794ad8ad2ae94095cddeb1de78c5dddaa7005c51af10b0176c69971a6d88e805a90c2b6550d76636e43c40d8427a808b8645ede885de4a0358e - languageName: node - linkType: hard - "cacheable-lookup@npm:^5.0.3": version: 5.0.4 resolution: "cacheable-lookup@npm:5.0.4" @@ -1967,26 +1891,6 @@ __metadata: languageName: node linkType: hard -"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": - version: 1.0.2 - resolution: "call-bind-apply-helpers@npm:1.0.2" - dependencies: - es-errors: "npm:^1.3.0" - function-bind: "npm:^1.1.2" - checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 - languageName: node - linkType: hard - -"call-bound@npm:^1.0.2": - version: 1.0.4 - resolution: "call-bound@npm:1.0.4" - dependencies: - call-bind-apply-helpers: "npm:^1.0.2" - get-intrinsic: "npm:^1.3.0" - checksum: 10c0/f4796a6a0941e71c766aea672f63b72bc61234c4f4964dc6d7606e3664c307e7d77845328a8f3359ce39ddb377fed67318f9ee203dea1d47e46165dcf2917644 - languageName: node - linkType: hard - "camelcase@npm:^6.0.0": version: 6.3.0 resolution: "camelcase@npm:6.3.0" @@ -2016,7 +1920,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.1.0, chalk@npm:^4.1.2": +"chalk@npm:^4.1.0": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -2144,36 +2048,6 @@ __metadata: languageName: node linkType: hard -"content-disposition@npm:~0.5.4": - version: 0.5.4 - resolution: "content-disposition@npm:0.5.4" - dependencies: - safe-buffer: "npm:5.2.1" - checksum: 10c0/bac0316ebfeacb8f381b38285dc691c9939bf0a78b0b7c2d5758acadad242d04783cee5337ba7d12a565a19075af1b3c11c728e1e4946de73c6ff7ce45f3f1bb - languageName: node - linkType: hard - -"content-type@npm:~1.0.4, content-type@npm:~1.0.5": - version: 1.0.5 - resolution: "content-type@npm:1.0.5" - checksum: 10c0/b76ebed15c000aee4678c3707e0860cb6abd4e680a598c0a26e17f0bfae723ec9cc2802f0ff1bc6e4d80603719010431d2231018373d4dde10f9ccff9dadf5af - languageName: node - linkType: hard - -"cookie-signature@npm:~1.0.6": - version: 1.0.7 - resolution: "cookie-signature@npm:1.0.7" - checksum: 10c0/e7731ad2995ae2efeed6435ec1e22cdd21afef29d300c27281438b1eab2bae04ef0d1a203928c0afec2cee72aa36540b8747406ebe308ad23c8e8cc3c26c9c51 - languageName: node - linkType: hard - -"cookie@npm:~0.7.1": - version: 0.7.2 - resolution: "cookie@npm:0.7.2" - checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 - languageName: node - linkType: hard - "core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" @@ -2218,22 +2092,6 @@ __metadata: languageName: node linkType: hard -"dateformat@npm:^4.6.3": - version: 4.6.3 - resolution: "dateformat@npm:4.6.3" - checksum: 10c0/e2023b905e8cfe2eb8444fb558562b524807a51cdfe712570f360f873271600b5c94aebffaf11efb285e2c072264a7cf243eadb68f3eba0f8cc85fb86cd25df6 - languageName: node - linkType: hard - -"debug@npm:2.6.9, debug@npm:^2.2.0": - version: 2.6.9 - resolution: "debug@npm:2.6.9" - dependencies: - ms: "npm:2.0.0" - checksum: 10c0/121908fb839f7801180b69a7e218a40b5a0b718813b886b7d6bdb82001b931c938e2941d1e4450f33a1b1df1da653f5f7a0440c197f29fbf8a6e9d45ff6ef589 - languageName: node - linkType: hard - "debug@npm:4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" @@ -2246,6 +2104,15 @@ __metadata: languageName: node linkType: hard +"debug@npm:^2.2.0": + version: 2.6.9 + resolution: "debug@npm:2.6.9" + dependencies: + ms: "npm:2.0.0" + checksum: 10c0/121908fb839f7801180b69a7e218a40b5a0b718813b886b7d6bdb82001b931c938e2941d1e4450f33a1b1df1da653f5f7a0440c197f29fbf8a6e9d45ff6ef589 + languageName: node + linkType: hard + "debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.5": version: 4.4.3 resolution: "debug@npm:4.4.3" @@ -2312,20 +2179,6 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0, depd@npm:~2.0.0": - version: 2.0.0 - resolution: "depd@npm:2.0.0" - checksum: 10c0/58bd06ec20e19529b06f7ad07ddab60e504d9e0faca4bd23079fac2d279c3594334d736508dc350e06e510aba5e22e4594483b3a6562ce7c17dd797f4cc4ad2c - languageName: node - linkType: hard - -"destroy@npm:1.2.0, destroy@npm:~1.2.0": - version: 1.2.0 - resolution: "destroy@npm:1.2.0" - checksum: 10c0/bd7633942f57418f5a3b80d5cb53898127bcf53e24cdf5d5f4396be471417671f0fee48a4ebe9a1e9defbde2a31280011af58a57e090ff822f589b443ed4e643 - languageName: node - linkType: hard - "detect-node@npm:^2.0.4": version: 2.1.0 resolution: "detect-node@npm:2.1.0" @@ -2361,17 +2214,6 @@ __metadata: languageName: node linkType: hard -"dunder-proto@npm:^1.0.1": - version: 1.0.1 - resolution: "dunder-proto@npm:1.0.1" - dependencies: - call-bind-apply-helpers: "npm:^1.0.1" - es-errors: "npm:^1.3.0" - gopd: "npm:^1.2.0" - checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 - languageName: node - linkType: hard - "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -2379,13 +2221,6 @@ __metadata: languageName: node linkType: hard -"ee-first@npm:1.1.1": - version: 1.1.1 - resolution: "ee-first@npm:1.1.1" - checksum: 10c0/b5bb125ee93161bc16bfe6e56c6b04de5ad2aa44234d8f644813cc95d861a6910903132b05093706de2b706599367c4130eb6d170f6b46895686b95f87d017b7 - languageName: node - linkType: hard - "electron-mocha@npm:^12.1.0": version: 12.3.1 resolution: "electron-mocha@npm:12.3.1" @@ -2437,13 +2272,6 @@ __metadata: languageName: node linkType: hard -"encodeurl@npm:~2.0.0": - version: 2.0.0 - resolution: "encodeurl@npm:2.0.0" - checksum: 10c0/5d317306acb13e6590e28e27924c754163946a2480de11865c991a3a7eed4315cd3fba378b543ca145829569eefe9b899f3d84bb09870f675ae60bc924b01ceb - languageName: node - linkType: hard - "end-of-stream@npm:^1.1.0": version: 1.4.5 resolution: "end-of-stream@npm:1.4.5" @@ -2467,7 +2295,7 @@ __metadata: languageName: node linkType: hard -"es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": +"es-define-property@npm:^1.0.0": version: 1.0.1 resolution: "es-define-property@npm:1.0.1" checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c @@ -2481,15 +2309,6 @@ __metadata: languageName: node linkType: hard -"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": - version: 1.1.1 - resolution: "es-object-atoms@npm:1.1.1" - dependencies: - es-errors: "npm:^1.3.0" - checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c - languageName: node - linkType: hard - "es6-error@npm:^4.1.1": version: 4.1.1 resolution: "es6-error@npm:4.1.1" @@ -2504,13 +2323,6 @@ __metadata: languageName: node linkType: hard -"escape-html@npm:~1.0.3": - version: 1.0.3 - resolution: "escape-html@npm:1.0.3" - checksum: 10c0/524c739d776b36c3d29fa08a22e03e8824e3b2fd57500e5e44ecf3cc4707c34c60f9ca0781c0e33d191f2991161504c295e98f68c78fe7baa6e57081ec6ac0a3 - languageName: node - linkType: hard - "escape-string-regexp@npm:4.0.0, escape-string-regexp@npm:^4.0.0": version: 4.0.0 resolution: "escape-string-regexp@npm:4.0.0" @@ -2518,13 +2330,6 @@ __metadata: languageName: node linkType: hard -"etag@npm:~1.8.1": - version: 1.8.1 - resolution: "etag@npm:1.8.1" - checksum: 10c0/12be11ef62fb9817314d790089a0a49fae4e1b50594135dcb8076312b7d7e470884b5100d249b28c18581b7fd52f8b485689ffae22a11ed9ec17377a33a08f84 - languageName: node - linkType: hard - "event-target-shim@npm:^5.0.0": version: 5.0.1 resolution: "event-target-shim@npm:5.0.1" @@ -2555,45 +2360,6 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.21.1": - version: 4.22.1 - resolution: "express@npm:4.22.1" - dependencies: - accepts: "npm:~1.3.8" - array-flatten: "npm:1.1.1" - body-parser: "npm:~1.20.3" - content-disposition: "npm:~0.5.4" - content-type: "npm:~1.0.4" - cookie: "npm:~0.7.1" - cookie-signature: "npm:~1.0.6" - debug: "npm:2.6.9" - depd: "npm:2.0.0" - encodeurl: "npm:~2.0.0" - escape-html: "npm:~1.0.3" - etag: "npm:~1.8.1" - finalhandler: "npm:~1.3.1" - fresh: "npm:~0.5.2" - http-errors: "npm:~2.0.0" - merge-descriptors: "npm:1.0.3" - methods: "npm:~1.1.2" - on-finished: "npm:~2.4.1" - parseurl: "npm:~1.3.3" - path-to-regexp: "npm:~0.1.12" - proxy-addr: "npm:~2.0.7" - qs: "npm:~6.14.0" - range-parser: "npm:~1.2.1" - safe-buffer: "npm:5.2.1" - send: "npm:~0.19.0" - serve-static: "npm:~1.16.2" - setprototypeof: "npm:1.2.0" - statuses: "npm:~2.0.1" - type-is: "npm:~1.6.18" - utils-merge: "npm:1.0.1" - vary: "npm:~1.1.2" - checksum: 10c0/ea57f512ab1e05e26b53a14fd432f65a10ec735ece342b37d0b63a7bcb8d337ffbb830ecb8ca15bcdfe423fbff88cea09786277baff200e8cde3ab40faa665cd - languageName: node - linkType: hard - "extract-zip@npm:^2.0.1": version: 2.0.1 resolution: "extract-zip@npm:2.0.1" @@ -2671,21 +2437,6 @@ __metadata: languageName: node linkType: hard -"finalhandler@npm:~1.3.1": - version: 1.3.2 - resolution: "finalhandler@npm:1.3.2" - dependencies: - debug: "npm:2.6.9" - encodeurl: "npm:~2.0.0" - escape-html: "npm:~1.0.3" - on-finished: "npm:~2.4.1" - parseurl: "npm:~1.3.3" - statuses: "npm:~2.0.2" - unpipe: "npm:~1.0.0" - checksum: 10c0/435a4fd65e4e4e4c71bb5474980090b73c353a123dd415583f67836bdd6516e528cf07298e219a82b94631dee7830eae5eece38d3c178073cf7df4e8c182f413 - languageName: node - linkType: hard - "find-up@npm:5.0.0, find-up@npm:^5.0.0": version: 5.0.0 resolution: "find-up@npm:5.0.0" @@ -2715,20 +2466,6 @@ __metadata: languageName: node linkType: hard -"forwarded@npm:0.2.0": - version: 0.2.0 - resolution: "forwarded@npm:0.2.0" - checksum: 10c0/9b67c3fac86acdbc9ae47ba1ddd5f2f81526fa4c8226863ede5600a3f7c7416ef451f6f1e240a3cc32d0fd79fcfe6beb08fd0da454f360032bde70bf80afbb33 - languageName: node - linkType: hard - -"fresh@npm:~0.5.2": - version: 0.5.2 - resolution: "fresh@npm:0.5.2" - checksum: 10c0/c6d27f3ed86cc5b601404822f31c900dd165ba63fff8152a3ef714e2012e7535027063bc67ded4cb5b3a49fa596495d46cacd9f47d6328459cf570f08b7d9e5a - languageName: node - linkType: hard - "fs-extra@npm:^8.1.0": version: 8.1.0 resolution: "fs-extra@npm:8.1.0" @@ -2766,20 +2503,6 @@ __metadata: languageName: node linkType: hard -"function-bind@npm:^1.1.2": - version: 1.1.2 - resolution: "function-bind@npm:1.1.2" - checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 - languageName: node - linkType: hard - -"generator-function@npm:^2.0.0": - version: 2.0.1 - resolution: "generator-function@npm:2.0.1" - checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8 - languageName: node - linkType: hard - "get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" @@ -2801,37 +2524,6 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.3.0": - version: 1.3.1 - resolution: "get-intrinsic@npm:1.3.1" - dependencies: - async-function: "npm:^1.0.0" - async-generator-function: "npm:^1.0.0" - call-bind-apply-helpers: "npm:^1.0.2" - es-define-property: "npm:^1.0.1" - es-errors: "npm:^1.3.0" - es-object-atoms: "npm:^1.1.1" - function-bind: "npm:^1.1.2" - generator-function: "npm:^2.0.0" - get-proto: "npm:^1.0.1" - gopd: "npm:^1.2.0" - has-symbols: "npm:^1.1.0" - hasown: "npm:^2.0.2" - math-intrinsics: "npm:^1.1.0" - checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d - languageName: node - linkType: hard - -"get-proto@npm:^1.0.1": - version: 1.0.1 - resolution: "get-proto@npm:1.0.1" - dependencies: - dunder-proto: "npm:^1.0.1" - es-object-atoms: "npm:^1.0.0" - checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c - languageName: node - linkType: hard - "get-stream@npm:^5.1.0": version: 5.2.0 resolution: "get-stream@npm:5.2.0" @@ -2903,7 +2595,7 @@ __metadata: languageName: node linkType: hard -"gopd@npm:^1.0.1, gopd@npm:^1.2.0": +"gopd@npm:^1.0.1": version: 1.2.0 resolution: "gopd@npm:1.2.0" checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead @@ -2952,22 +2644,6 @@ __metadata: languageName: node linkType: hard -"has-symbols@npm:^1.1.0": - version: 1.1.0 - resolution: "has-symbols@npm:1.1.0" - checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e - languageName: node - linkType: hard - -"hasown@npm:^2.0.2": - version: 2.0.3 - resolution: "hasown@npm:2.0.3" - dependencies: - function-bind: "npm:^1.1.2" - checksum: 10c0/f5eb28c3fd0d3e4facd821c1eeee3836c37b70ab0b0fc532e8a39976e18fef43652415dadc52f8c7a5ff6d5ac93b7bef128789aa6f90f4e9b9a9083dce74ab38 - languageName: node - linkType: hard - "he@npm:1.2.0, he@npm:^1.2.0": version: 1.2.0 resolution: "he@npm:1.2.0" @@ -2984,26 +2660,6 @@ __metadata: languageName: node linkType: hard -"http-errors@npm:~2.0.0, http-errors@npm:~2.0.1": - version: 2.0.1 - resolution: "http-errors@npm:2.0.1" - dependencies: - depd: "npm:~2.0.0" - inherits: "npm:~2.0.4" - setprototypeof: "npm:~1.2.0" - statuses: "npm:~2.0.2" - toidentifier: "npm:~1.0.1" - checksum: 10c0/fb38906cef4f5c83952d97661fe14dc156cb59fe54812a42cd448fa57b5c5dfcb38a40a916957737bd6b87aab257c0648d63eb5b6a9ca9f548e105b6072712d4 - languageName: node - linkType: hard - -"http2-express@npm:^1.0.0": - version: 1.1.0 - resolution: "http2-express@npm:1.1.0" - checksum: 10c0/a2f11b474f48cc8e95d7e786f9f5dccaeeb23aa5467b863403fc85ecd7e0159742c78e456a1189879b3b43f469da6e8b71b54c7d61011d1602828522e9a1eda6 - languageName: node - linkType: hard - "http2-wrapper@npm:^1.0.0-beta.5.2": version: 1.0.3 resolution: "http2-wrapper@npm:1.0.3" @@ -3014,15 +2670,6 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:~0.4.24": - version: 0.4.24 - resolution: "iconv-lite@npm:0.4.24" - dependencies: - safer-buffer: "npm:>= 2.1.2 < 3" - checksum: 10c0/c6886a24cc00f2a059767440ec1bc00d334a89f250db8e0f7feb4961c8727118457e27c495ba94d082e51d3baca378726cd110aaf7ded8b9bbfd6a44760cf1d4 - languageName: node - linkType: hard - "ieee754@npm:^1.1.4, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" @@ -3047,13 +2694,6 @@ __metadata: languageName: node linkType: hard -"ipaddr.js@npm:1.9.1": - version: 1.9.1 - resolution: "ipaddr.js@npm:1.9.1" - checksum: 10c0/0486e775047971d3fdb5fb4f063829bac45af299ae0b82dcf3afa2145338e08290563a2a70f34b732d795ecc8311902e541a8530eeb30d75860a78ff4e94ce2a - languageName: node - linkType: hard - "is-binary-path@npm:~2.1.0": version: 2.1.0 resolution: "is-binary-path@npm:2.1.0" @@ -3260,7 +2900,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.15, lodash@npm:^4.17.21": +"lodash@npm:^4.17.15": version: 4.18.1 resolution: "lodash@npm:4.18.1" checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27 @@ -3339,13 +2979,6 @@ __metadata: languageName: node linkType: hard -"math-intrinsics@npm:^1.1.0": - version: 1.1.0 - resolution: "math-intrinsics@npm:1.1.0" - checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f - languageName: node - linkType: hard - "md5@npm:^2.1.0": version: 2.3.0 resolution: "md5@npm:2.3.0" @@ -3364,52 +2997,6 @@ __metadata: languageName: node linkType: hard -"media-typer@npm:0.3.0": - version: 0.3.0 - resolution: "media-typer@npm:0.3.0" - checksum: 10c0/d160f31246907e79fed398470285f21bafb45a62869dc469b1c8877f3f064f5eabc4bcc122f9479b8b605bc5c76187d7871cf84c4ee3ecd3e487da1993279928 - languageName: node - linkType: hard - -"merge-descriptors@npm:1.0.3": - version: 1.0.3 - resolution: "merge-descriptors@npm:1.0.3" - checksum: 10c0/866b7094afd9293b5ea5dcd82d71f80e51514bed33b4c4e9f516795dc366612a4cbb4dc94356e943a8a6914889a914530badff27f397191b9b75cda20b6bae93 - languageName: node - linkType: hard - -"methods@npm:~1.1.2": - version: 1.1.2 - resolution: "methods@npm:1.1.2" - checksum: 10c0/bdf7cc72ff0a33e3eede03708c08983c4d7a173f91348b4b1e4f47d4cdbf734433ad971e7d1e8c77247d9e5cd8adb81ea4c67b0a2db526b758b2233d7814b8b2 - languageName: node - linkType: hard - -"mime-db@npm:1.52.0": - version: 1.52.0 - resolution: "mime-db@npm:1.52.0" - checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa - languageName: node - linkType: hard - -"mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": - version: 2.1.35 - resolution: "mime-types@npm:2.1.35" - dependencies: - mime-db: "npm:1.52.0" - checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 - languageName: node - linkType: hard - -"mime@npm:1.6.0": - version: 1.6.0 - resolution: "mime@npm:1.6.0" - bin: - mime: cli.js - checksum: 10c0/b92cd0adc44888c7135a185bfd0dddc42c32606401c72896a842ae15da71eb88858f17669af41e498b463cd7eb998f7b48939a25b08374c7924a9c8a6f8a81b0 - languageName: node - linkType: hard - "mimic-response@npm:^1.0.0": version: 1.0.1 resolution: "mimic-response@npm:1.0.1" @@ -3460,7 +3047,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0, minimist@npm:^1.2.6, minimist@npm:^1.2.8": +"minimist@npm:^1.2.0, minimist@npm:^1.2.6": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 @@ -3494,15 +3081,6 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^2.1.6": - version: 2.1.6 - resolution: "mkdirp@npm:2.1.6" - bin: - mkdirp: dist/cjs/src/bin.js - checksum: 10c0/96f551c651dd8f5f9435d53df1a7b9bfc553be769ee6da5192c37c1f303a376ef1c6996f96913d4a8d357060451d4526a346031d1919f92c58806a5fa3cd8dfe - languageName: node - linkType: hard - "mocha-junit-reporter@npm:^1.22.0": version: 1.23.3 resolution: "mocha-junit-reporter@npm:1.23.3" @@ -3602,13 +3180,6 @@ __metadata: languageName: node linkType: hard -"negotiator@npm:0.6.3": - version: 0.6.3 - resolution: "negotiator@npm:0.6.3" - checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2 - languageName: node - linkType: hard - "node-addon-api@npm:^7.1.1": version: 7.1.1 resolution: "node-addon-api@npm:7.1.1" @@ -3638,25 +3209,6 @@ __metadata: languageName: node linkType: hard -"node-media-server@npm:2.7.2": - version: 2.7.2 - resolution: "node-media-server@npm:2.7.2" - dependencies: - basic-auth-connect: "npm:^1.1.0" - chalk: "npm:^4.1.2" - dateformat: "npm:^4.6.3" - express: "npm:^4.21.1" - http2-express: "npm:^1.0.0" - lodash: "npm:^4.17.21" - minimist: "npm:^1.2.8" - mkdirp: "npm:^2.1.6" - ws: "npm:^8.18.0" - bin: - node-media-server: bin/app.js - checksum: 10c0/e14a5c51a65a7d643b532e8e79fd92148a85fe3612964a029b92d07c8719a4bb441d0437e74e66df6c5980aedf7bfb83a3d3ed00dc5649b6e175cd77b614ad10 - languageName: node - linkType: hard - "nopt@npm:^9.0.0": version: 9.0.0 resolution: "nopt@npm:9.0.0" @@ -3682,13 +3234,6 @@ __metadata: languageName: node linkType: hard -"object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": - version: 1.13.4 - resolution: "object-inspect@npm:1.13.4" - checksum: 10c0/d7f8711e803b96ea3191c745d6f8056ce1f2496e530e6a19a0e92d89b0fa3c76d910c31f0aa270432db6bd3b2f85500a376a83aaba849a8d518c8845b3211692 - languageName: node - linkType: hard - "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -3696,15 +3241,6 @@ __metadata: languageName: node linkType: hard -"on-finished@npm:~2.4.1": - version: 2.4.1 - resolution: "on-finished@npm:2.4.1" - dependencies: - ee-first: "npm:1.1.1" - checksum: 10c0/46fb11b9063782f2d9968863d9cbba33d77aa13c17f895f56129c274318b86500b22af3a160fe9995aa41317efcd22941b6eba747f718ced08d9a73afdb087b4 - languageName: node - linkType: hard - "once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -3746,13 +3282,6 @@ __metadata: languageName: node linkType: hard -"parseurl@npm:~1.3.3": - version: 1.3.3 - resolution: "parseurl@npm:1.3.3" - checksum: 10c0/90dd4760d6f6174adb9f20cf0965ae12e23879b5f5464f38e92fce8073354341e4b3b76fa3d878351efe7d01e617121955284cfd002ab087fba1a0726ec0b4f5 - languageName: node - linkType: hard - "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -3784,13 +3313,6 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:~0.1.12": - version: 0.1.13 - resolution: "path-to-regexp@npm:0.1.13" - checksum: 10c0/1cae3921739c154a8926e136185a10c916f79a249b9072a5001b266d96e193860ca03867e8e8cc808b786862d750f427ed93686bc259355442c3407a62deab1a - languageName: node - linkType: hard - "pathval@npm:^1.1.1": version: 1.1.1 resolution: "pathval@npm:1.1.1" @@ -3854,16 +3376,6 @@ __metadata: languageName: node linkType: hard -"proxy-addr@npm:~2.0.7": - version: 2.0.7 - resolution: "proxy-addr@npm:2.0.7" - dependencies: - forwarded: "npm:0.2.0" - ipaddr.js: "npm:1.9.1" - checksum: 10c0/c3eed999781a35f7fd935f398b6d8920b6fb00bbc14287bc6de78128ccc1a02c89b95b56742bf7cf0362cc333c61d138532049c7dedc7a328ef13343eff81210 - languageName: node - linkType: hard - "pump@npm:^3.0.0": version: 3.0.4 resolution: "pump@npm:3.0.4" @@ -3881,24 +3393,6 @@ __metadata: languageName: node linkType: hard -"qs@npm:~6.14.0": - version: 6.14.2 - resolution: "qs@npm:6.14.2" - dependencies: - side-channel: "npm:^1.1.0" - checksum: 10c0/646110124476fc9acf3c80994c8c3a0600cbad06a4ede1c9e93341006e8426d64e85e048baf8f0c4995f0f1bf0f37d1f3acc5ec1455850b81978792969a60ef6 - languageName: node - linkType: hard - -"qs@npm:~6.15.1": - version: 6.15.1 - resolution: "qs@npm:6.15.1" - dependencies: - side-channel: "npm:^1.1.0" - checksum: 10c0/19ee504f0ebff72598503e38cd6d9bd7b52a8ab62ae18b1e6bee3d4db58469bd65871ef1893a881bafb0f80ef2f9ab586e1f255cf25cc8d816c0f5a704721d97 - languageName: node - linkType: hard - "quick-lru@npm:^5.1.1": version: 5.1.1 resolution: "quick-lru@npm:5.1.1" @@ -3915,25 +3409,6 @@ __metadata: languageName: node linkType: hard -"range-parser@npm:~1.2.1": - version: 1.2.1 - resolution: "range-parser@npm:1.2.1" - checksum: 10c0/96c032ac2475c8027b7a4e9fe22dc0dfe0f6d90b85e496e0f016fbdb99d6d066de0112e680805075bd989905e2123b3b3d002765149294dce0c1f7f01fcc2ea0 - languageName: node - linkType: hard - -"raw-body@npm:~2.5.3": - version: 2.5.3 - resolution: "raw-body@npm:2.5.3" - dependencies: - bytes: "npm:~3.1.2" - http-errors: "npm:~2.0.1" - iconv-lite: "npm:~0.4.24" - unpipe: "npm:~1.0.0" - checksum: 10c0/449844344fc90547fb994383a494b83300e4f22199f146a79f68d78a199a8f2a923ea9fd29c3be979bfd50291a3884733619ffc15ba02a32e703b612f8d3f74a - languageName: node - linkType: hard - "readable-stream@npm:^2.0.5": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" @@ -4035,7 +3510,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -4049,13 +3524,6 @@ __metadata: languageName: node linkType: hard -"safer-buffer@npm:>= 2.1.2 < 3": - version: 2.1.2 - resolution: "safer-buffer@npm:2.1.2" - checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 - languageName: node - linkType: hard - "semver-compare@npm:^1.0.0": version: 1.0.0 resolution: "semver-compare@npm:1.0.0" @@ -4081,27 +3549,6 @@ __metadata: languageName: node linkType: hard -"send@npm:~0.19.0, send@npm:~0.19.1": - version: 0.19.2 - resolution: "send@npm:0.19.2" - dependencies: - debug: "npm:2.6.9" - depd: "npm:2.0.0" - destroy: "npm:1.2.0" - encodeurl: "npm:~2.0.0" - escape-html: "npm:~1.0.3" - etag: "npm:~1.8.1" - fresh: "npm:~0.5.2" - http-errors: "npm:~2.0.1" - mime: "npm:1.6.0" - ms: "npm:2.1.3" - on-finished: "npm:~2.4.1" - range-parser: "npm:~1.2.1" - statuses: "npm:~2.0.2" - checksum: 10c0/20c2389fe0fdf3fc499938cac598bc32272287e993c4960717381a10de8550028feadfb9076f959a3a3ebdea42e1f690e116f0d16468fa56b9fd41866d3dc267 - languageName: node - linkType: hard - "serialize-error@npm:^7.0.1": version: 7.0.1 resolution: "serialize-error@npm:7.0.1" @@ -4129,25 +3576,6 @@ __metadata: languageName: node linkType: hard -"serve-static@npm:~1.16.2": - version: 1.16.3 - resolution: "serve-static@npm:1.16.3" - dependencies: - encodeurl: "npm:~2.0.0" - escape-html: "npm:~1.0.3" - parseurl: "npm:~1.3.3" - send: "npm:~0.19.1" - checksum: 10c0/36320397a073c71bedf58af48a4a100fe6d93f07459af4d6f08b9a7217c04ce2a4939e0effd842dc7bece93ffcd59eb52f58c4fff2a8e002dc29ae6b219cd42b - languageName: node - linkType: hard - -"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": - version: 1.2.0 - resolution: "setprototypeof@npm:1.2.0" - checksum: 10c0/68733173026766fa0d9ecaeb07f0483f4c2dc70ca376b3b7c40b7cda909f94b0918f6c5ad5ce27a9160bdfb475efaa9d5e705a11d8eaae18f9835d20976028bc - languageName: node - linkType: hard - "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -4164,54 +3592,6 @@ __metadata: languageName: node linkType: hard -"side-channel-list@npm:^1.0.0": - version: 1.0.1 - resolution: "side-channel-list@npm:1.0.1" - dependencies: - es-errors: "npm:^1.3.0" - object-inspect: "npm:^1.13.4" - checksum: 10c0/d346c787fd2f9f1c2fdea14f00e8250118db0e7596d85a6cb9faa75f105d31a73a8f7a341c93d7df2a2429098c3d37a77bd3be9e88c37094b8c01807bc77c7a2 - languageName: node - linkType: hard - -"side-channel-map@npm:^1.0.1": - version: 1.0.1 - resolution: "side-channel-map@npm:1.0.1" - dependencies: - call-bound: "npm:^1.0.2" - es-errors: "npm:^1.3.0" - get-intrinsic: "npm:^1.2.5" - object-inspect: "npm:^1.13.3" - checksum: 10c0/010584e6444dd8a20b85bc926d934424bd809e1a3af941cace229f7fdcb751aada0fb7164f60c2e22292b7fa3c0ff0bce237081fd4cdbc80de1dc68e95430672 - languageName: node - linkType: hard - -"side-channel-weakmap@npm:^1.0.2": - version: 1.0.2 - resolution: "side-channel-weakmap@npm:1.0.2" - dependencies: - call-bound: "npm:^1.0.2" - es-errors: "npm:^1.3.0" - get-intrinsic: "npm:^1.2.5" - object-inspect: "npm:^1.13.3" - side-channel-map: "npm:^1.0.1" - checksum: 10c0/71362709ac233e08807ccd980101c3e2d7efe849edc51455030327b059f6c4d292c237f94dc0685031dd11c07dd17a68afde235d6cf2102d949567f98ab58185 - languageName: node - linkType: hard - -"side-channel@npm:^1.1.0": - version: 1.1.0 - resolution: "side-channel@npm:1.1.0" - dependencies: - es-errors: "npm:^1.3.0" - object-inspect: "npm:^1.13.3" - side-channel-list: "npm:^1.0.0" - side-channel-map: "npm:^1.0.1" - side-channel-weakmap: "npm:^1.0.2" - checksum: 10c0/cb20dad41eb032e6c24c0982e1e5a24963a28aa6122b4f05b3f3d6bf8ae7fd5474ef382c8f54a6a3ab86e0cac4d41a23bd64ede3970e5bfb50326ba02a7996e6 - languageName: node - linkType: hard - "signal-exit@npm:^4.0.1": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" @@ -4243,13 +3623,6 @@ __metadata: languageName: node linkType: hard -"statuses@npm:~2.0.1, statuses@npm:~2.0.2": - version: 2.0.2 - resolution: "statuses@npm:2.0.2" - checksum: 10c0/a9947d98ad60d01f6b26727570f3bcceb6c8fa789da64fe6889908fe2e294d57503b14bf2b5af7605c2d36647259e856635cd4c49eab41667658ec9d0080ec3f - languageName: node - linkType: hard - "stream-browserify@npm:3.0.0": version: 3.0.0 resolution: "stream-browserify@npm:3.0.0" @@ -4441,13 +3814,6 @@ __metadata: languageName: node linkType: hard -"toidentifier@npm:~1.0.1": - version: 1.0.1 - resolution: "toidentifier@npm:1.0.1" - checksum: 10c0/93937279934bd66cc3270016dd8d0afec14fb7c94a05c72dc57321f8bd1fa97e5bea6d1f7c89e728d077ca31ea125b78320a616a6c6cd0e6b9cb94cb864381c1 - languageName: node - linkType: hard - "ts-node@npm:^7.0.1": version: 7.0.1 resolution: "ts-node@npm:7.0.1" @@ -4473,13 +3839,6 @@ __metadata: languageName: node linkType: hard -"tsscmp@npm:^1.0.6": - version: 1.0.6 - resolution: "tsscmp@npm:1.0.6" - checksum: 10c0/2f79a9455e7e3e8071995f98cdf3487ccfc91b760bec21a9abb4d90519557eafaa37246e87c92fa8bf3fef8fd30cfd0cc3c4212bb929baa9fb62494bfa4d24b2 - languageName: node - linkType: hard - "type-detect@npm:^4.0.0, type-detect@npm:^4.1.0": version: 4.1.0 resolution: "type-detect@npm:4.1.0" @@ -4494,16 +3853,6 @@ __metadata: languageName: node linkType: hard -"type-is@npm:~1.6.18": - version: 1.6.18 - resolution: "type-is@npm:1.6.18" - dependencies: - media-typer: "npm:0.3.0" - mime-types: "npm:~2.1.24" - checksum: 10c0/a23daeb538591b7efbd61ecf06b6feb2501b683ffdc9a19c74ef5baba362b4347e42f1b4ed81f5882a8c96a3bfff7f93ce3ffaf0cbbc879b532b04c97a55db9d - languageName: node - linkType: hard - "typedoc-plugin-markdown@npm:^4.0.0": version: 4.11.0 resolution: "typedoc-plugin-markdown@npm:4.11.0" @@ -4592,13 +3941,6 @@ __metadata: languageName: node linkType: hard -"unpipe@npm:~1.0.0": - version: 1.0.0 - resolution: "unpipe@npm:1.0.0" - checksum: 10c0/193400255bd48968e5c5383730344fbb4fa114cdedfab26e329e50dd2d81b134244bb8a72c6ac1b10ab0281a58b363d06405632c9d49ca9dfd5e90cbd7d0f32c - languageName: node - linkType: hard - "util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -4606,13 +3948,6 @@ __metadata: languageName: node linkType: hard -"utils-merge@npm:1.0.1": - version: 1.0.1 - resolution: "utils-merge@npm:1.0.1" - checksum: 10c0/02ba649de1b7ca8854bfe20a82f1dfbdda3fb57a22ab4a8972a63a34553cf7aa51bc9081cf7e001b035b88186d23689d69e71b510e610a09a4c66f68aa95b672 - languageName: node - linkType: hard - "uuid@npm:^9.0.0": version: 9.0.1 resolution: "uuid@npm:9.0.1" @@ -4622,13 +3957,6 @@ __metadata: languageName: node linkType: hard -"vary@npm:~1.1.2": - version: 1.1.2 - resolution: "vary@npm:1.1.2" - checksum: 10c0/f15d588d79f3675135ba783c91a4083dcd290a2a5be9fcb6514220a1634e23df116847b1cc51f66bfb0644cf9353b2abb7815ae499bab06e46dd33c1a6bf1f4f - languageName: node - linkType: hard - "wait-queue@npm:^1.1.4": version: 1.1.4 resolution: "wait-queue@npm:1.1.4" @@ -4712,21 +4040,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.0": - version: 8.20.0 - resolution: "ws@npm:8.20.0" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10c0/956ac5f11738c914089b65878b9223692ace77337ba55379ae68e1ecbeae9b47a0c6eb9403688f609999a58c80d83d99865fe0029b229d308b08c1ef93d4ea14 - languageName: node - linkType: hard - "xml@npm:^1.0.0": version: 1.0.1 resolution: "xml@npm:1.0.1" From e325be918483ec385221f16ba46012b43bf51159 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Fri, 17 Jul 2026 21:33:45 +0100 Subject: [PATCH 5/7] YouTube bandwidth probing in AutoConfig 2 --- js/module.d.ts | 32 +- js/module.ts | 44 +- obs-studio-client/CMakeLists.txt | 2 + .../source/nodeobs_autoconfig.cpp | 40 +- .../tests/autoconfig-probe-policy-test.cpp | 86 ++ .../source/nodeobs_autoconfig.cpp | 798 +++++++++++++++--- obs-studio-server/source/nodeobs_autoconfig.h | 1 + source/autoconfig-probe-policy.hpp | 87 ++ .../src/test_osn_auto_optimizer_v1.ts | 142 +++- 9 files changed, 1095 insertions(+), 137 deletions(-) create mode 100644 obs-studio-client/tests/autoconfig-probe-policy-test.cpp create mode 100644 source/autoconfig-probe-policy.hpp diff --git a/js/module.d.ts b/js/module.d.ts index 2e08de30e..4720791ce 100644 --- a/js/module.d.ts +++ b/js/module.d.ts @@ -996,7 +996,8 @@ export interface IAutoConfigCapabilities { awaitableCancel: true; perUploadLegResults: true; desktopOwnedApply: true; - bandwidthModes: ['twitch-standard-active', 'estimate']; + multipleActiveProbes: true; + bandwidthModes: ['twitch-standard-active', 'youtube-unbound-active', 'estimate']; } export type AutoConfigTopology = 'direct-single' | 'cloud-multistream' | 'custom-rtmp' | 'dual-output' | 'enhanced-broadcasting' | 'stream-shift' | 'mixed'; export type AutoConfigDisplay = 'horizontal' | 'vertical' | 'both'; @@ -1030,18 +1031,28 @@ export interface IAutoConfigLegRequest { limits?: IAutoConfigLimits; estimateReason?: AutoConfigEstimateReason; } -export interface IAutoConfigActiveProbe { +export interface IAutoConfigTwitchActiveProbe { + probeId: string; kind: 'twitch-standard-v1'; legId: string; serviceName: 'Twitch'; server: string; streamKey: string; } +export interface IAutoConfigYoutubeActiveProbe { + probeId: string; + kind: 'youtube-unbound-v1'; + legId: string; + serviceName: 'YouTube - RTMPS'; + server: string; + streamKey: string; +} +export type IAutoConfigActiveProbe = IAutoConfigTwitchActiveProbe | IAutoConfigYoutubeActiveProbe; export interface IAutoConfigRequest { schemaVersion: 1; topology: AutoConfigTopology; legs: IAutoConfigLegRequest[]; - activeProbe?: IAutoConfigActiveProbe; + activeProbes?: IAutoConfigActiveProbe[]; } export type AutoConfigEventType = 'phase' | 'progress' | 'result' | 'error' | 'cancelled' | 'complete'; export type AutoConfigPhase = 'preflight' | 'hardware' | 'bandwidth' | 'recommendation' | 'cleanup'; @@ -1056,11 +1067,25 @@ export interface IAutoConfigEvent { code?: string; legId?: string; measurementMode?: AutoConfigMeasurementMode; + probeId?: string; + provider?: 'twitch' | 'youtube'; + /** Applied video bitrate for the active probe substep; audio is additional. */ + targetBitrateKbps?: number; +} +export interface IAutoConfigProbeMeasurement { + provider: 'twitch' | 'youtube'; + method: 'twitch-bandwidth-test-v1' | 'youtube-unbound-ramp-v1'; + success: boolean; + measuredKbps?: number; + safeKbps?: number; + headroomPercent?: number; + ceilingReached: boolean; } export interface IAutoConfigMeasurement { mode: AutoConfigMeasurementMode; confidence: 'high' | 'medium' | 'low'; reason?: string; + probes?: IAutoConfigProbeMeasurement[]; } export interface IAutoConfigRecommendation { width: number; @@ -1097,6 +1122,7 @@ export interface IAutoConfigNativeApi { GetAutoConfigCapabilities(): string; CreateAutoConfigSession(requestJson: string, callback: (event: IAutoConfigEvent) => void): string; StartAutoConfigSession(sessionId: string): void; + ConfirmAutoConfigProbeIngest(sessionId: string, probeId: string, received: boolean): void; GetAutoConfigResult(sessionId: string): string; CancelAutoConfigSession(sessionId: string): void; CloseAutoConfigSession(sessionId: string): void; diff --git a/js/module.ts b/js/module.ts index 883512bde..8ec11b28b 100644 --- a/js/module.ts +++ b/js/module.ts @@ -1970,7 +1970,8 @@ export interface IAutoConfigCapabilities { awaitableCancel: true; perUploadLegResults: true; desktopOwnedApply: true; - bandwidthModes: ['twitch-standard-active', 'estimate']; + multipleActiveProbes: true; + bandwidthModes: ['twitch-standard-active', 'youtube-unbound-active', 'estimate']; } export type AutoConfigTopology = @@ -2035,7 +2036,8 @@ export interface IAutoConfigLegRequest { estimateReason?: AutoConfigEstimateReason; } -export interface IAutoConfigActiveProbe { +export interface IAutoConfigTwitchActiveProbe { + probeId: string; kind: 'twitch-standard-v1'; legId: string; serviceName: 'Twitch'; @@ -2043,11 +2045,29 @@ export interface IAutoConfigActiveProbe { streamKey: string; } +export interface IAutoConfigYoutubeActiveProbe { + /** + * Security contract: Desktop's trusted worker must create an exact-marked, + * reusable-but-unbound liveStream and status-confirm that same resource. + * Native validates the official RTMPS endpoint but cannot query YouTube + * resource ownership or binding. Desktop must close the native session + * before deleting the liveStream through the YouTube API. + */ + probeId: string; + kind: 'youtube-unbound-v1'; + legId: string; + serviceName: 'YouTube - RTMPS'; + server: string; + streamKey: string; +} + +export type IAutoConfigActiveProbe = IAutoConfigTwitchActiveProbe | IAutoConfigYoutubeActiveProbe; + export interface IAutoConfigRequest { schemaVersion: 1; topology: AutoConfigTopology; legs: IAutoConfigLegRequest[]; - activeProbe?: IAutoConfigActiveProbe; + activeProbes?: IAutoConfigActiveProbe[]; } export type AutoConfigEventType = 'phase' | 'progress' | 'result' | 'error' | 'cancelled' | 'complete'; @@ -2064,12 +2084,29 @@ export interface IAutoConfigEvent { code?: string; legId?: string; measurementMode?: AutoConfigMeasurementMode; + probeId?: string; + provider?: 'twitch' | 'youtube'; + /** Applied video bitrate for the active probe substep; audio is additional. */ + targetBitrateKbps?: number; +} + +export interface IAutoConfigProbeMeasurement { + provider: 'twitch' | 'youtube'; + method: 'twitch-bandwidth-test-v1' | 'youtube-unbound-ramp-v1'; + success: boolean; + /** Observed aggregate RTMP throughput, including audio. */ + measuredKbps?: number; + /** Safe video bitrate after headroom and the probe audio reserve. */ + safeKbps?: number; + headroomPercent?: number; + ceilingReached: boolean; } export interface IAutoConfigMeasurement { mode: AutoConfigMeasurementMode; confidence: 'high' | 'medium' | 'low'; reason?: string; + probes?: IAutoConfigProbeMeasurement[]; } export interface IAutoConfigRecommendation { @@ -2113,6 +2150,7 @@ export interface IAutoConfigNativeApi { GetAutoConfigCapabilities(): string; CreateAutoConfigSession(requestJson: string, callback: (event: IAutoConfigEvent) => void): string; StartAutoConfigSession(sessionId: string): void; + ConfirmAutoConfigProbeIngest(sessionId: string, probeId: string, received: boolean): void; GetAutoConfigResult(sessionId: string): string; CancelAutoConfigSession(sessionId: string): void; CloseAutoConfigSession(sessionId: string): void; diff --git a/obs-studio-client/CMakeLists.txt b/obs-studio-client/CMakeLists.txt index 9c8c9b838..d6854f963 100644 --- a/obs-studio-client/CMakeLists.txt +++ b/obs-studio-client/CMakeLists.txt @@ -179,6 +179,7 @@ if(BUILD_TESTING) "tests/polling-pacer-test.cpp" "tests/best-effort-gate-test.cpp" "tests/polling-pacer-best-effort-gate-test.cpp" + "tests/autoconfig-probe-policy-test.cpp" ${OSN_CLIENT_CORE_SOURCES} ) @@ -186,6 +187,7 @@ if(BUILD_TESTING) obs_studio_client_unit_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/source" + "${CMAKE_SOURCE_DIR}/source" ) target_link_libraries( diff --git a/obs-studio-client/source/nodeobs_autoconfig.cpp b/obs-studio-client/source/nodeobs_autoconfig.cpp index 287031fe0..0fb1098b4 100644 --- a/obs-studio-client/source/nodeobs_autoconfig.cpp +++ b/obs-studio-client/source/nodeobs_autoconfig.cpp @@ -38,6 +38,9 @@ struct AutoConfigEvent { std::string code; std::string legId; std::string measurementMode; + std::string probeId; + std::string provider; + uint32_t targetBitrateKbps = 0; }; std::atomic workerStop{true}; @@ -133,6 +136,12 @@ void DispatchEvent(AutoConfigEvent *event) result.Set("legId", Napi::String::New(env, eventData->legId)); if (!eventData->measurementMode.empty()) result.Set("measurementMode", Napi::String::New(env, eventData->measurementMode)); + if (!eventData->probeId.empty()) + result.Set("probeId", Napi::String::New(env, eventData->probeId)); + if (!eventData->provider.empty()) + result.Set("provider", Napi::String::New(env, eventData->provider)); + if (eventData->targetBitrateKbps > 0) + result.Set("targetBitrateKbps", Napi::Number::New(env, eventData->targetBitrateKbps)); jsCallback.Call({result}); } catch (...) { @@ -157,7 +166,7 @@ void Worker() if (conn && !sessionId.empty()) { std::vector response = conn->call_synchronous_helper("AutoConfig", "QueryAutoConfigSession", {ipc::value(sessionId)}); - if (response.size() >= 10 && static_cast(response[0].value_union.ui64) == ErrorCode::Ok) { + if (response.size() >= 12 && static_cast(response[0].value_union.ui64) == ErrorCode::Ok) { auto *event = new AutoConfigEvent; event->schemaVersion = static_cast(ReadUnsigned(response[1])); event->sessionId = response[2].value_str; @@ -168,6 +177,10 @@ void Worker() event->code = response[7].value_str; event->legId = response[8].value_str; event->measurementMode = response[9].value_str; + event->probeId = response[10].value_str; + event->provider = response[11].value_str; + if (response.size() >= 13) + event->targetBitrateKbps = static_cast(ReadUnsigned(response[12])); if (event->sessionId == sessionId) DispatchEvent(event); @@ -312,6 +325,30 @@ Napi::Value StartAutoConfigSession(const Napi::CallbackInfo &info) return info.Env().Undefined(); } +Napi::Value ConfirmAutoConfigProbeIngest(const Napi::CallbackInfo &info) +{ + if (info.Length() < 3 || !info[0].IsString() || !info[1].IsString() || !info[2].IsBoolean()) { + Napi::TypeError::New(info.Env(), "ConfirmAutoConfigProbeIngest expects (sessionId: string, probeId: string, received: boolean)") + .ThrowAsJavaScriptException(); + return info.Env().Undefined(); + } + const std::string sessionId = info[0].As().Utf8Value(); + const std::string probeId = info[1].As().Utf8Value(); + if (sessionId.empty() || probeId.empty()) { + Napi::TypeError::New(info.Env(), "ConfirmAutoConfigProbeIngest expects non-empty sessionId and probeId").ThrowAsJavaScriptException(); + return info.Env().Undefined(); + } + auto conn = GetConnection(info); + if (!conn) + return info.Env().Undefined(); + std::vector response = conn->call_synchronous_helper("AutoConfig", "ConfirmAutoConfigProbeIngest", + {ipc::value(sessionId), ipc::value(probeId), + ipc::value((uint32_t)(info[2].As().Value() ? 1 : 0))}); + if (!ValidateResponse(info, response)) + return info.Env().Undefined(); + return info.Env().Undefined(); +} + Napi::Value GetAutoConfigResult(const Napi::CallbackInfo &info) { std::string sessionId; @@ -406,6 +443,7 @@ void autoConfig::Init(Napi::Env env, Napi::Object exports) exports.Set("GetAutoConfigCapabilities", Napi::Function::New(env, GetAutoConfigCapabilities)); exports.Set("CreateAutoConfigSession", Napi::Function::New(env, CreateAutoConfigSession)); exports.Set("StartAutoConfigSession", Napi::Function::New(env, StartAutoConfigSession)); + exports.Set("ConfirmAutoConfigProbeIngest", Napi::Function::New(env, ConfirmAutoConfigProbeIngest)); exports.Set("GetAutoConfigResult", Napi::Function::New(env, GetAutoConfigResult)); exports.Set("CancelAutoConfigSession", Napi::Function::New(env, CancelAutoConfigSession)); exports.Set("CloseAutoConfigSession", Napi::Function::New(env, CloseAutoConfigSession)); diff --git a/obs-studio-client/tests/autoconfig-probe-policy-test.cpp b/obs-studio-client/tests/autoconfig-probe-policy-test.cpp new file mode 100644 index 000000000..4435e79d6 --- /dev/null +++ b/obs-studio-client/tests/autoconfig-probe-policy-test.cpp @@ -0,0 +1,86 @@ +#include "autoconfig-probe-policy.hpp" + +#include + +using autoConfig::probePolicy::YoutubeRampEvidence; +using autoConfig::probePolicy::clampEstimateToObservedSafe; +using autoConfig::probePolicy::effectiveProbeCeilingKbps; +using autoConfig::probePolicy::hasProbeThroughputMetrics; +using autoConfig::probePolicy::probeSubstepProgress; +using autoConfig::probePolicy::reachedEffectiveProbeCeiling; +using autoConfig::probePolicy::safeVideoKbps; + +TEST_CASE("YouTube first-rung failure retains a conservative observed cap") +{ + YoutubeRampEvidence evidence; + evidence.observe(750, false, 1128); + + CHECK_FALSE(evidence.passedStep); + CHECK(evidence.recommendationBasisKbps == 750); + CHECK(evidence.safeVideoKbps(80, 128) == 472); + CHECK(clampEstimateToObservedSafe(6000, evidence.safeVideoKbps(80, 128), 12000) == 472); +} + +TEST_CASE("YouTube failed first rung cannot recommend above the attempted rate after a burst") +{ + YoutubeRampEvidence evidence; + evidence.observe(2000, false, 1128); + + CHECK_FALSE(evidence.passedStep); + CHECK(evidence.recommendationBasisKbps == 2000); + CHECK(evidence.failedUpperBoundKbps == 1128); + CHECK(evidence.safeVideoKbps(80, 128) == 774); + CHECK(clampEstimateToObservedSafe(6000, evidence.safeVideoKbps(80, 128), 12000) == 774); +} + +TEST_CASE("YouTube failed higher rung cannot raise the last passing recommendation") +{ + YoutubeRampEvidence evidence; + evidence.observe(2100, true, 2128); + evidence.observe(1900, false, 4128); + + CHECK(evidence.passedStep); + CHECK(evidence.recommendationBasisKbps == 2100); + CHECK(evidence.safeVideoKbps(80, 128) == 1392); + CHECK(clampEstimateToObservedSafe(1400, evidence.safeVideoKbps(80, 128), 12000) == 1392); +} + +TEST_CASE("Aggregate probe throughput reserves audio before recommending video bitrate") +{ + CHECK(safeVideoKbps(6000, 70, 32) == 4168); + CHECK(safeVideoKbps(128, 80, 128) == 0); +} + +TEST_CASE("Successful zero-safe probe metrics remain present in result provenance") +{ + CHECK(hasProbeThroughputMetrics(true, 0)); + CHECK(hasProbeThroughputMetrics(false, 128)); + CHECK_FALSE(hasProbeThroughputMetrics(false, 0)); +} + +TEST_CASE("YouTube reports the exact effective probe cap as ceiling reached") +{ + const int requestCeiling = effectiveProbeCeilingKbps(12000, 0, 6000); + CHECK(requestCeiling == 6000); + CHECK_FALSE(reachedEffectiveProbeCeiling(4000, requestCeiling)); + CHECK(reachedEffectiveProbeCeiling(6000, requestCeiling)); + + const int platformCeiling = effectiveProbeCeilingKbps(12000, 4500, 6000); + CHECK(platformCeiling == 4500); + CHECK(reachedEffectiveProbeCeiling(4500, platformCeiling)); +} + +TEST_CASE("Probe substeps advance monotonically within their provider progress slot") +{ + double previous = 30.0; + for (size_t index = 0; index < 7; index++) { + const double progress = probeSubstepProgress(30.0, 65.0, index, 7); + CHECK(progress > previous); + CHECK(progress < 65.0); + previous = progress; + } + + CHECK(probeSubstepProgress(30.0, 47.5, 6, 7) < 47.5); + CHECK(probeSubstepProgress(47.5, 65.0, 0, 1) > 47.5); + CHECK(probeSubstepProgress(30.0, 65.0, 0, 0) == 30.0); +} diff --git a/obs-studio-server/source/nodeobs_autoconfig.cpp b/obs-studio-server/source/nodeobs_autoconfig.cpp index bc151bcfd..9ceabf04c 100644 --- a/obs-studio-server/source/nodeobs_autoconfig.cpp +++ b/obs-studio-server/source/nodeobs_autoconfig.cpp @@ -9,6 +9,7 @@ #include "nodeobs_autoconfig.h" +#include "autoconfig-probe-policy.hpp" #include "osn-encoders.hpp" #include "osn-error.hpp" #include "shared.hpp" @@ -24,8 +25,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -42,9 +45,20 @@ constexpr int kProbeConnectTimeoutMs = 8000; constexpr int kProbeWarmupMs = 750; constexpr int kProbeSampleMs = 5000; constexpr int kProbeStopTimeoutMs = 3000; +constexpr int kYoutubeIngestConfirmationTimeoutMs = 15000; constexpr int kCancelTimeoutMs = 8000; constexpr uint64_t kProbeMaxBytes = 25ULL * 1024ULL * 1024ULL; +constexpr uint64_t kYoutubeProbeMaxBytes = 64ULL * 1024ULL * 1024ULL; constexpr int kProbeMaximumBitrateKbps = 10000; +constexpr int kYoutubeProbeMaximumBitrateKbps = 12000; +constexpr int kYoutubeProbeInitialBitrateKbps = 1000; +constexpr int kYoutubeProbeSettleMs = 500; +constexpr int kYoutubeProbeSampleMs = 5000; +constexpr int kYoutubeProbeTotalTimeoutMs = 100000; +constexpr int kTwitchProbeSafeMultiplierPercent = 70; +constexpr int kYoutubeProbeSafeMultiplierPercent = 80; +constexpr int kTwitchProbeAudioBitrateKbps = 32; +constexpr int kYoutubeProbeAudioBitrateKbps = 128; constexpr int kDefaultEstimatedBitrateKbps = 2500; constexpr int kHardwareWarmupMs = 500; constexpr int kHardwareSampleMs = 1500; @@ -104,12 +118,25 @@ struct LegRequest { }; struct ProbeRequest { - bool present = false; + std::string probeId; std::string kind; std::string legId; std::string serviceName; std::string server; std::string streamKey; + std::string provider; + bool eligible = false; + std::string denialReason; +}; + +struct MeasurementProvenance { + std::string provider; + std::string method; + uint64_t measuredKbps = 0; + uint64_t safeKbps = 0; + int headroomPercent = 0; + bool success = false; + bool ceilingReached = false; }; struct Recommendation { @@ -121,6 +148,7 @@ struct Recommendation { std::string confidence = "medium"; std::string reason; CurrentSettings value; + std::vector probes; }; struct SessionEvent { @@ -131,15 +159,16 @@ struct SessionEvent { std::string code; std::string legId; std::string measurementMode; + std::string probeId; + std::string provider; + uint32_t targetBitrateKbps = 0; }; struct Session : std::enable_shared_from_this { std::string id; std::string topology; std::vector legs; - ProbeRequest probe; - bool activeProbeEligible = false; - std::string activeProbeDenialReason; + std::vector probes; std::atomic state{SessionState::Created}; std::atomic cancelRequested{false}; @@ -158,6 +187,12 @@ struct Session : std::enable_shared_from_this { // mutex, so it can request a force-stop without racing release. std::mutex probeMutex; obs_output_t *activeProbeOutput = nullptr; + std::mutex probeConfirmationMutex; + std::condition_variable probeConfirmationCondition; + // 0 = pending, 1 = accepted, -1 = rejected. Only YouTube probes use + // this gate; Twitch retains its bandwidth-test-key behavior. + std::map probeConfirmations; + std::string activeConfirmationProbeId; }; std::mutex sessionsMutex; @@ -201,6 +236,52 @@ static bool isOfficialTwitchServer(const std::string &server) return host == "live.twitch.tv" || hasSuffix(host, ".twitch.tv") || host == "live-video.net" || hasSuffix(host, ".live-video.net"); } +static bool containsWhitespaceOrControl(const std::string &value) +{ + return std::any_of(value.begin(), value.end(), [](unsigned char ch) { return std::isspace(ch) || std::iscntrl(ch); }); +} + +static bool isBoundedTwitchKey(const std::string &key) +{ + return !key.empty() && key.size() <= 4096 && !containsWhitespaceOrControl(key); +} + +static bool isBoundedYoutubeKey(const std::string &key) +{ + if (key.empty() || key.size() > 1024 || containsWhitespaceOrControl(key)) + return false; + return key.find_first_of("/\\?#@:") == std::string::npos; +} + +static bool isOfficialYoutubeRtmpsServer(const std::string &server) +{ + if (server.empty() || server.size() > 2048 || containsWhitespaceOrControl(server)) + return false; + + const std::string value = lowerCopy(server); + constexpr const char *scheme = "rtmps://"; + if (value.compare(0, std::strlen(scheme), scheme) != 0 || value.find_first_of("?#") != std::string::npos) + return false; + + const size_t authorityStart = std::strlen(scheme); + const size_t pathStart = value.find('/', authorityStart); + if (pathStart == std::string::npos || value.substr(pathStart) != "/live2") + return false; + + const std::string authority = value.substr(authorityStart, pathStart - authorityStart); + if (authority.empty() || authority.find('@') != std::string::npos) + return false; + + std::string host = authority; + const size_t portSeparator = authority.find(':'); + if (portSeparator != std::string::npos) { + if (authority.find(':', portSeparator + 1) != std::string::npos || authority.substr(portSeparator + 1) != "443") + return false; + host = authority.substr(0, portSeparator); + } + return host == "a.rtmps.youtube.com"; +} + static void trim(std::string &value) { while (!value.empty() && std::isspace((unsigned char)value.back())) @@ -383,40 +464,106 @@ static bool parseRequest(const std::string &json, Session &session, std::string if (legs) obs_data_array_release(legs); - obs_data_t *probe = obs_data_get_obj(root, "activeProbe"); - if (valid && probe) { - session.probe.present = true; - session.probe.kind = obs_data_get_string(probe, "kind"); - session.probe.legId = obs_data_get_string(probe, "legId"); - session.probe.serviceName = obs_data_get_string(probe, "serviceName"); - session.probe.server = obs_data_get_string(probe, "server"); - session.probe.streamKey = obs_data_get_string(probe, "streamKey"); - } - if (probe) - obs_data_release(probe); + obs_data_array_t *probes = obs_data_get_array(root, "activeProbes"); + const size_t probeCount = probes ? obs_data_array_count(probes) : 0; + if (valid && probeCount > 16) { + error = "invalid_autoconfig_active_probes"; + valid = false; + } + std::set probeIds; + for (size_t i = 0; valid && i < probeCount; i++) { + obs_data_t *item = obs_data_array_item(probes, i); + ProbeRequest probe; + probe.probeId = obs_data_get_string(item, "probeId"); + probe.kind = obs_data_get_string(item, "kind"); + probe.legId = obs_data_get_string(item, "legId"); + probe.serviceName = obs_data_get_string(item, "serviceName"); + probe.server = obs_data_get_string(item, "server"); + probe.streamKey = obs_data_get_string(item, "streamKey"); + obs_data_release(item); + + if (probe.probeId.empty() || probe.probeId.size() > 128 || probe.legId.empty() || probe.legId.size() > 128 || + !probeIds.insert(probe.probeId).second) { + error = "invalid_autoconfig_probe_identity"; + valid = false; + break; + } + if (probe.kind == "twitch-standard-v1") + probe.provider = "twitch"; + else if (probe.kind == "youtube-unbound-v1") + probe.provider = "youtube"; + session.probes.push_back(std::move(probe)); + } + if (probes) + obs_data_array_release(probes); obs_data_release(root); if (!valid) return false; - // Active probing is deliberately default-deny. A request that does not meet - // every invariant remains usable, but all legs are estimated and the secret - // is discarded before any network object can be created. - session.activeProbeEligible = - session.probe.present && session.topology == "direct-single" && session.legs.size() == 1 && session.legs[0].destinations.size() == 1 && - session.legs[0].destinations[0].platform == "twitch" && session.probe.kind == "twitch-standard-v1" && session.probe.serviceName == "Twitch" && - session.probe.legId == session.legs[0].legId && !session.probe.streamKey.empty() && isOfficialTwitchServer(session.probe.server); - if (session.probe.present && !session.activeProbeEligible) { - session.activeProbeDenialReason = "active_probe_not_eligible"; - session.probe.streamKey.clear(); - session.probe.server.clear(); + // Active probing is deliberately default-deny. Credentials survive parsing + // only when the probe, upload leg, topology, and destination all agree. + const bool multipleDualOutputLegs = session.topology == "dual-output" && session.legs.size() > 1; + std::map probePairCounts; + for (const auto &probe : session.probes) + probePairCounts[probe.legId + "\n" + probe.provider]++; + + for (auto &probe : session.probes) { + const auto legIt = std::find_if(session.legs.begin(), session.legs.end(), [&](const LegRequest &leg) { return leg.legId == probe.legId; }); + const bool legFound = legIt != session.legs.end(); + const bool destinationFound = legFound && std::any_of(legIt->destinations.begin(), legIt->destinations.end(), + [&](const Destination &destination) { return destination.platform == probe.provider; }); + const bool directEligible = session.topology == "direct-single" && session.legs.size() == 1 && legFound && legIt->destinations.size() == 1; + const bool dualEligible = session.topology == "dual-output" && session.legs.size() == 1 && legFound && legIt->destinations.size() == 1; + const bool cloudEligible = session.topology == "cloud-multistream" && session.legs.size() == 1 && legFound; + const bool providerValid = (probe.provider == "twitch" && probe.serviceName == "Twitch" && isOfficialTwitchServer(probe.server) && + isBoundedTwitchKey(probe.streamKey)) || + (probe.provider == "youtube" && probe.serviceName == "YouTube - RTMPS" && + isOfficialYoutubeRtmpsServer(probe.server) && isBoundedYoutubeKey(probe.streamKey)); + + probe.eligible = !probe.provider.empty() && destinationFound && (directEligible || dualEligible || cloudEligible) && providerValid && + probePairCounts[probe.legId + "\n" + probe.provider] == 1; + if (!probe.eligible) { + probe.denialReason = multipleDualOutputLegs ? "dual_output_multiple_active_legs" : "active_probe_not_eligible"; + probe.streamKey.clear(); + probe.server.clear(); + } + if (probe.eligible && probe.provider == "youtube") + session.probeConfirmations.emplace(probe.probeId, 0); + } + + // A shared cloud upload is active-measured only when every Twitch/YouTube + // destination has exactly one eligible probe. Otherwise a partial provider + // sample could recommend a bitrate that is unsafe for the unmeasured peer. + if (session.topology == "cloud-multistream" && session.legs.size() == 1) { + const LegRequest &leg = session.legs.front(); + bool completeProviderSet = true; + for (const auto &destination : leg.destinations) { + if (destination.platform != "twitch" && destination.platform != "youtube") + continue; + const size_t eligibleCount = std::count_if(session.probes.begin(), session.probes.end(), [&](const ProbeRequest &probe) { + return probe.eligible && probe.legId == leg.legId && probe.provider == destination.platform; + }); + completeProviderSet = completeProviderSet && eligibleCount == 1; + } + if (!completeProviderSet) { + for (auto &probe : session.probes) { + if (probe.legId == leg.legId) { + probe.eligible = false; + probe.denialReason = "active_probe_set_incomplete"; + probe.streamKey.clear(); + probe.server.clear(); + } + } + } } return true; } static void pushEvent(const std::shared_ptr &session, const char *type, const char *phase, double progress, const std::string &code = {}, - const std::string &legId = {}, const std::string &measurementMode = {}) + const std::string &legId = {}, const std::string &measurementMode = {}, const std::string &probeId = {}, const std::string &provider = {}, + uint32_t targetBitrateKbps = 0) { std::lock_guard lock(session->mutex); SessionEvent event; @@ -427,6 +574,9 @@ static void pushEvent(const std::shared_ptr &session, const char *type, event.code = code; event.legId = legId; event.measurementMode = measurementMode; + event.probeId = probeId; + event.provider = provider; + event.targetBitrateKbps = targetBitrateKbps; session->events.push(std::move(event)); } @@ -678,6 +828,26 @@ 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.probes.empty()) { + obs_data_array_t *probes = obs_data_array_create(); + for (const auto &provenance : recommendation.probes) { + obs_data_t *probe = obs_data_create(); + obs_data_set_string(probe, "provider", provenance.provider.c_str()); + obs_data_set_string(probe, "method", provenance.method.c_str()); + obs_data_set_bool(probe, "success", provenance.success); + if (probePolicy::hasProbeThroughputMetrics(provenance.success, provenance.measuredKbps)) { + obs_data_set_int(probe, "measuredKbps", (long long)provenance.measuredKbps); + obs_data_set_int(probe, "safeKbps", (long long)provenance.safeKbps); + } + if (provenance.success || provenance.headroomPercent > 0) + obs_data_set_int(probe, "headroomPercent", provenance.headroomPercent); + obs_data_set_bool(probe, "ceilingReached", provenance.ceilingReached); + obs_data_array_push_back(probes, probe); + obs_data_release(probe); + } + obs_data_set_array(measurement, "probes", probes); + obs_data_array_release(probes); + } obs_data_set_obj(leg, "measurement", measurement); obs_data_release(measurement); @@ -710,7 +880,13 @@ struct ProbeResult { bool success = false; bool cancelled = false; uint64_t measuredKbps = 0; + uint64_t safeKbps = 0; int platformCapKbps = 0; + int headroomPercent = 0; + bool ceilingReached = false; + std::string provider; + std::string method; + std::string legId; std::string errorCode; }; @@ -720,6 +896,39 @@ static bool silentAudioCallback(void *, uint64_t startTimestamp, uint64_t, uint6 return true; } +static float nextAudioNoiseSample(uint32_t &state) +{ + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + return ((float)(state & 0xffffU) / 32767.5f - 1.0f) * 0.25f; +} + +static bool noiseAudioCallback(void *param, uint64_t startTimestamp, uint64_t, uint64_t *outputTimestamp, uint32_t activeMixers, + struct audio_data_mixes_outputs *mixes) +{ + *outputTimestamp = startTimestamp; + if (!param || !mixes) + return false; + + uint32_t &state = *static_cast(param); + for (size_t canvas = 0; canvas < mixes->outputs.num; canvas++) { + for (size_t mix = 0; mix < MAX_AUDIO_MIXES; mix++) { + if (!(activeMixers & (1U << mix))) + continue; + + for (size_t channel = 0; channel < 2; channel++) { + float *plane = mixes->outputs.array[canvas].output[mix].data[channel]; + if (!plane) + continue; + for (size_t frame = 0; frame < AUDIO_OUTPUT_FRAMES; frame++) + plane[frame] = nextAudioNoiseSample(state); + } + } + } + return true; +} + class ScratchResources { public: explicit ScratchResources(Session &session_, int stopTimeoutMs_ = kProbeStopTimeoutMs) : session(session_), stopTimeoutMs(stopTimeoutMs_) {} @@ -749,6 +958,7 @@ class ScratchResources { std::thread feeder; std::vector framePatternA; std::vector framePatternB; + uint32_t audioNoiseState = 0xa341316cU; bool createSyntheticVideo(uint32_t width, uint32_t height, uint32_t fpsNum, uint32_t fpsDen, bool useCoreVideoMix = false) { @@ -819,14 +1029,15 @@ class ScratchResources { return true; } - bool createSyntheticAudio() + bool createSyntheticAudio(bool useNoise = false) { audio_output_info info{}; info.name = "auto_optimizer_synthetic_audio"; info.samples_per_sec = 48000; info.format = AUDIO_FORMAT_FLOAT_PLANAR; info.speakers = SPEAKERS_STEREO; - info.input_callback = silentAudioCallback; + info.input_callback = useNoise ? noiseAudioCallback : silentAudioCallback; + info.input_param = useNoise ? &audioNoiseState : nullptr; return audio_output_open(&syntheticAudio, &info) == AUDIO_OUTPUT_SUCCESS; } @@ -1263,78 +1474,142 @@ static HardwareAssessment assessHardware(const std::shared_ptr &session return assessment; } -static ProbeResult runTwitchProbe(const std::shared_ptr &session, const LegRequest &leg) +static bool waitForProbeInterval(const std::shared_ptr &session, obs_output_t *output, std::chrono::steady_clock::time_point deadline, + ProbeResult &result) +{ + while (std::chrono::steady_clock::now() < deadline) { + if (session->cancelRequested.load()) { + result.cancelled = true; + obs_output_force_stop(output); + return false; + } + if (!obs_output_active(output)) { + result.errorCode = result.provider + "_probe_disconnected"; + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + return true; +} + +static bool waitForYoutubeIngestConfirmation(const std::shared_ptr &session, const ProbeRequest &probe, obs_output_t *output, + std::chrono::steady_clock::time_point deadline, double progress, ProbeResult &result) +{ + { + std::lock_guard lock(session->probeConfirmationMutex); + session->activeConfirmationProbeId = probe.probeId; + } + pushEvent(session, "progress", "bandwidth", progress, "youtube_probe_waiting_for_ingest", probe.legId, "active", probe.probeId, probe.provider); + std::unique_lock lock(session->probeConfirmationMutex); + const bool signalled = session->probeConfirmationCondition.wait_until(lock, deadline, [&]() { + const auto found = session->probeConfirmations.find(probe.probeId); + return session->cancelRequested.load() || found == session->probeConfirmations.end() || found->second != 0; + }); + if (session->cancelRequested.load()) { + result.cancelled = true; + lock.unlock(); + obs_output_force_stop(output); + return false; + } + const auto found = session->probeConfirmations.find(probe.probeId); + if (!signalled || found == session->probeConfirmations.end() || found->second == 0) { + result.errorCode = "youtube_probe_ingest_confirmation_timeout"; + return false; + } + if (found->second < 0) { + result.errorCode = "youtube_probe_ingest_not_received"; + return false; + } + return true; +} + +static ProbeResult runRtmpProbe(const std::shared_ptr &session, ProbeRequest &probe, const LegRequest &leg, double slotStartProgress, + double slotEndProgress) { ProbeResult result; + result.provider = probe.provider; + result.legId = probe.legId; + result.method = probe.provider == "youtube" ? "youtube-unbound-ramp-v1" : "twitch-bandwidth-test-v1"; + result.headroomPercent = 100 - (probe.provider == "youtube" ? kYoutubeProbeSafeMultiplierPercent : kTwitchProbeSafeMultiplierPercent); ScratchResources resources(*session); obs_data_t *serviceSettings = obs_data_create(); - obs_data_set_string(serviceSettings, "service", "Twitch"); - obs_data_set_string(serviceSettings, "server", session->probe.server.c_str()); - const std::string bandwidthKey = normalizeTwitchBandwidthKey(session->probe.streamKey); - obs_data_set_string(serviceSettings, "key", bandwidthKey.c_str()); - resources.service = obs_service_create_private("rtmp_common", "auto_optimizer_twitch_probe_service", serviceSettings); + obs_data_set_string(serviceSettings, "service", probe.provider == "youtube" ? "YouTube - RTMPS" : "Twitch"); + obs_data_set_string(serviceSettings, "server", probe.server.c_str()); + std::string serviceKey = probe.provider == "youtube" ? probe.streamKey : normalizeTwitchBandwidthKey(probe.streamKey); + obs_data_set_string(serviceSettings, "key", serviceKey.c_str()); + resources.service = obs_service_create_private("rtmp_common", "auto_optimizer_probe_service", serviceSettings); obs_data_release(serviceSettings); - // Drop the only application-owned copy as soon as the disposable service has - // consumed it. It is never included in events, result JSON, or logs. - session->probe.streamKey.clear(); + // Drop the only application-owned copies as soon as the disposable service + // has consumed them. Neither value is emitted, serialized, or logged. + probe.streamKey.clear(); + probe.server.clear(); + serviceKey.clear(); if (!resources.service) { - result.errorCode = "twitch_probe_service_create_failed"; + result.errorCode = result.provider + "_probe_service_create_failed"; return result; } - obs_data_t *encoderSettings = obs_data_create(); - const int requested = std::clamp(std::max(leg.current.bitrateKbps, 6000), 500, kProbeMaximumBitrateKbps); - obs_data_set_int(encoderSettings, "bitrate", requested); - obs_data_set_string(encoderSettings, "rate_control", "CBR"); - obs_data_set_string(encoderSettings, "preset", "veryfast"); - obs_data_set_int(encoderSettings, "keyint_sec", 2); - + const int maximumBitrate = probe.provider == "youtube" ? kYoutubeProbeMaximumBitrateKbps : kProbeMaximumBitrateKbps; + const int requested = probe.provider == "youtube" ? kYoutubeProbeInitialBitrateKbps + : std::clamp(std::max(leg.current.bitrateKbps, 6000), 500, maximumBitrate); obs_data_t *platformProbe = obs_data_create(); - obs_data_set_int(platformProbe, "bitrate", kProbeMaximumBitrateKbps); + 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"); - if (platformReturned > 0 && platformReturned < kProbeMaximumBitrateKbps) + if (platformReturned > 0 && platformReturned < maximumBitrate) result.platformCapKbps = platformReturned; obs_data_release(platformProbe); + + int initialBitrate = requested; if (result.platformCapKbps > 0) - obs_data_set_int(encoderSettings, "bitrate", std::min(requested, result.platformCapKbps)); + initialBitrate = std::min(initialBitrate, result.platformCapKbps); + if (leg.limits.maxBitrateKbps > 0) + initialBitrate = std::min(initialBitrate, leg.limits.maxBitrateKbps); + obs_data_t *encoderSettings = obs_data_create(); + obs_data_set_int(encoderSettings, "bitrate", initialBitrate); + obs_data_set_string(encoderSettings, "rate_control", "CBR"); + obs_data_set_string(encoderSettings, "preset", "veryfast"); + obs_data_set_int(encoderSettings, "keyint_sec", 2); obs_service_apply_encoder_settings(resources.service, encoderSettings, nullptr); + initialBitrate = (int)obs_data_get_int(encoderSettings, "bitrate"); - if (!resources.createSyntheticVideo(128, 128, 30, 1)) { + const uint32_t width = probe.provider == "youtube" ? 640 : 128; + const uint32_t height = probe.provider == "youtube" ? 360 : 128; + if (!resources.createSyntheticVideo(width, height, 30, 1)) { obs_data_release(encoderSettings); - result.errorCode = "twitch_probe_video_create_failed"; + result.errorCode = result.provider + "_probe_video_create_failed"; return result; } - if (!resources.createSyntheticAudio()) { + if (!resources.createSyntheticAudio(probe.provider == "youtube")) { obs_data_release(encoderSettings); - result.errorCode = "twitch_probe_audio_create_failed"; + result.errorCode = result.provider + "_probe_audio_create_failed"; return result; } - resources.videoEncoder = obs_video_encoder_create(ADVANCED_ENCODER_X264, "auto_optimizer_twitch_probe_encoder", encoderSettings, nullptr); + resources.videoEncoder = obs_video_encoder_create(ADVANCED_ENCODER_X264, "auto_optimizer_probe_encoder", encoderSettings, nullptr); obs_data_release(encoderSettings); if (!resources.videoEncoder) { - result.errorCode = "twitch_probe_encoder_create_failed"; + result.errorCode = result.provider + "_probe_encoder_create_failed"; return result; } - obs_encoder_set_video(resources.videoEncoder, resources.syntheticVideo); + obs_data_t *audioEncoderSettings = obs_data_create(); - obs_data_set_int(audioEncoderSettings, "bitrate", 32); - resources.audioEncoder = obs_audio_encoder_create("ffmpeg_aac", "auto_optimizer_twitch_probe_audio_encoder", audioEncoderSettings, 0, nullptr); + obs_data_set_int(audioEncoderSettings, "bitrate", probe.provider == "youtube" ? kYoutubeProbeAudioBitrateKbps : kTwitchProbeAudioBitrateKbps); + resources.audioEncoder = obs_audio_encoder_create("ffmpeg_aac", "auto_optimizer_probe_audio_encoder", audioEncoderSettings, 0, nullptr); obs_data_release(audioEncoderSettings); if (!resources.audioEncoder) { - result.errorCode = "twitch_probe_audio_encoder_create_failed"; + result.errorCode = result.provider + "_probe_audio_encoder_create_failed"; return result; } obs_encoder_set_audio(resources.audioEncoder, resources.syntheticAudio); resources.startFeeder(); - resources.output = obs_output_create("rtmp_output", "auto_optimizer_twitch_probe_output", nullptr, nullptr); + resources.output = obs_output_create("rtmp_output", "auto_optimizer_probe_output", nullptr, nullptr); if (!resources.output) { - result.errorCode = "twitch_probe_output_create_failed"; + result.errorCode = result.provider + "_probe_output_create_failed"; return result; } obs_output_set_reconnect_settings(resources.output, 0, 0); @@ -1347,12 +1622,14 @@ static ProbeResult runTwitchProbe(const std::shared_ptr &session, const result.cancelled = true; return result; } + const auto probeStarted = std::chrono::steady_clock::now(); + const auto youtubeDeadline = probeStarted + std::chrono::milliseconds(kYoutubeProbeTotalTimeoutMs); if (!obs_output_start(resources.output)) { - result.errorCode = "twitch_probe_start_failed"; + result.errorCode = result.provider + "_probe_start_failed"; return result; } - const auto connectDeadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kProbeConnectTimeoutMs); + const auto connectDeadline = std::min(probeStarted + std::chrono::milliseconds(kProbeConnectTimeoutMs), youtubeDeadline); while (!obs_output_active(resources.output) && std::chrono::steady_clock::now() < connectDeadline) { if (session->cancelRequested.load()) { result.cancelled = true; @@ -1362,69 +1639,231 @@ static ProbeResult runTwitchProbe(const std::shared_ptr &session, const std::this_thread::sleep_for(std::chrono::milliseconds(50)); } if (!obs_output_active(resources.output)) { - result.errorCode = "twitch_probe_connect_failed"; + result.errorCode = result.provider + "_probe_connect_failed"; return result; } - const auto warmupDeadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kProbeWarmupMs); - while (std::chrono::steady_clock::now() < warmupDeadline) { - if (session->cancelRequested.load()) { - result.cancelled = true; - obs_output_force_stop(resources.output); - return result; - } - if (!obs_output_active(resources.output)) { - result.errorCode = "twitch_probe_disconnected"; + if (probe.provider == "youtube") { + blog(LOG_INFO, "[Auto Optimizer][YouTube Probe] RTMP output is active; waiting for ingest confirmation"); + const auto confirmationDeadline = + std::min(std::chrono::steady_clock::now() + std::chrono::milliseconds(kYoutubeIngestConfirmationTimeoutMs), youtubeDeadline); + const double confirmationProgress = std::min(slotStartProgress + 2.0, slotEndProgress); + if (!waitForYoutubeIngestConfirmation(session, probe, resources.output, confirmationDeadline, confirmationProgress, result)) { + blog(LOG_WARNING, "[Auto Optimizer][YouTube Probe] Ingest confirmation failed: reason=%s, output_active=%s", + result.errorCode.empty() ? (result.cancelled ? "cancelled" : "unknown") : result.errorCode.c_str(), + obs_output_active(resources.output) ? "true" : "false"); return result; } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); + blog(LOG_INFO, "[Auto Optimizer][YouTube Probe] Ingest confirmed; starting bandwidth ladder"); } - const uint64_t startBytes = obs_output_get_total_bytes(resources.output); - const auto sampleStart = std::chrono::steady_clock::now(); - const auto sampleDeadline = sampleStart + std::chrono::milliseconds(kProbeSampleMs); - while (std::chrono::steady_clock::now() < sampleDeadline) { - if (session->cancelRequested.load()) { - result.cancelled = true; - obs_output_force_stop(resources.output); + if (probe.provider == "twitch") { + pushEvent(session, "progress", "bandwidth", probePolicy::probeSubstepProgress(slotStartProgress, slotEndProgress, 0, 1), + "twitch_probe_measuring", probe.legId, "active", probe.probeId, probe.provider, (uint32_t)std::max(0, initialBitrate)); + if (!waitForProbeInterval(session, resources.output, std::chrono::steady_clock::now() + std::chrono::milliseconds(kProbeWarmupMs), result)) return result; + const uint64_t startBytes = obs_output_get_total_bytes(resources.output); + const auto sampleStart = std::chrono::steady_clock::now(); + const auto sampleDeadline = sampleStart + std::chrono::milliseconds(kProbeSampleMs); + while (std::chrono::steady_clock::now() < sampleDeadline && obs_output_get_total_bytes(resources.output) - startBytes < kProbeMaxBytes) { + if (!waitForProbeInterval(session, resources.output, std::chrono::steady_clock::now() + std::chrono::milliseconds(50), result)) + return result; } - if (!obs_output_active(resources.output)) { - result.errorCode = "twitch_probe_disconnected"; + const auto sampleEnd = std::chrono::steady_clock::now(); + const uint64_t endBytes = obs_output_get_total_bytes(resources.output); + const uint64_t elapsedNs = (uint64_t)std::chrono::duration_cast(sampleEnd - sampleStart).count(); + if (endBytes <= startBytes || elapsedNs == 0) { + result.errorCode = "twitch_probe_no_data"; return result; } - if (obs_output_get_total_bytes(resources.output) - startBytes >= kProbeMaxBytes) - break; - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - - const auto sampleEnd = std::chrono::steady_clock::now(); - const uint64_t endBytes = obs_output_get_total_bytes(resources.output); - const uint64_t elapsedNs = (uint64_t)std::chrono::duration_cast(sampleEnd - sampleStart).count(); - if (endBytes <= startBytes || elapsedNs == 0) { - result.errorCode = "twitch_probe_no_data"; - return result; + result.measuredKbps = ((endBytes - startBytes) * 8ULL * 1000000000ULL) / elapsedNs / 1000ULL; + result.safeKbps = probePolicy::safeVideoKbps(result.measuredKbps, kTwitchProbeSafeMultiplierPercent, kTwitchProbeAudioBitrateKbps); + if (result.platformCapKbps > 0) + result.safeKbps = std::min(result.safeKbps, (uint64_t)result.platformCapKbps); + result.success = result.measuredKbps > 0; + } else { + const int ladder[] = {1000, 2000, 4000, 6000, 8000, 10000, 12000}; + uint64_t totalProbeBytes = 0; + probePolicy::YoutubeRampEvidence rampEvidence; + const int effectiveCeilingKbps = + probePolicy::effectiveProbeCeilingKbps(kYoutubeProbeMaximumBitrateKbps, result.platformCapKbps, leg.limits.maxBitrateKbps); + std::vector> plannedTargets; + int lastPlannedTarget = 0; + for (int ladderTarget : ladder) { + const int plannedTarget = std::min(ladderTarget, effectiveCeilingKbps); + if (plannedTarget > lastPlannedTarget) { + plannedTargets.emplace_back(ladderTarget, plannedTarget); + lastPlannedTarget = plannedTarget; + } + } + int lastTarget = 0; + size_t emittedRungCount = 0; + std::string terminationReason = "ladder_exhausted"; + const auto rampStarted = std::chrono::steady_clock::now(); + const auto probeElapsedMs = std::chrono::duration_cast(rampStarted - probeStarted).count(); + const auto remainingMs = std::chrono::duration_cast(youtubeDeadline - rampStarted).count(); + blog(LOG_INFO, + "[Auto Optimizer][YouTube Probe] Ladder configuration: effective_video_ceiling=%d Kbps, settle=%d ms, sample=%d ms, " + "total_timeout=%d ms, probe_elapsed=%lld ms, remaining=%lld ms, byte_budget=%llu", + effectiveCeilingKbps, kYoutubeProbeSettleMs, kYoutubeProbeSampleMs, kYoutubeProbeTotalTimeoutMs, (long long)probeElapsedMs, + (long long)remainingMs, (unsigned long long)kYoutubeProbeMaxBytes); + for (const auto &[ladderTarget, plannedTarget] : plannedTargets) { + const auto rungCheckTime = std::chrono::steady_clock::now(); + if (rungCheckTime + std::chrono::milliseconds(kYoutubeProbeSettleMs + kYoutubeProbeSampleMs) >= youtubeDeadline) { + terminationReason = "total_deadline_before_next_rung"; + const auto rungRemainingMs = std::chrono::duration_cast(youtubeDeadline - rungCheckTime).count(); + blog(LOG_INFO, "[Auto Optimizer][YouTube Probe] Skipping ladder target %d Kbps: remaining=%lld ms, required=%d ms", + ladderTarget, (long long)rungRemainingMs, kYoutubeProbeSettleMs + kYoutubeProbeSampleMs); + break; + } + int target = plannedTarget; + if (target <= 0 || target <= lastTarget) + continue; + + obs_data_t *updatedSettings = obs_data_create(); + obs_data_set_int(updatedSettings, "bitrate", target); + obs_data_set_string(updatedSettings, "rate_control", "CBR"); + obs_data_set_string(updatedSettings, "preset", "veryfast"); + obs_data_set_int(updatedSettings, "keyint_sec", 2); + obs_service_apply_encoder_settings(resources.service, updatedSettings, nullptr); + target = (int)obs_data_get_int(updatedSettings, "bitrate"); + obs_encoder_update(resources.videoEncoder, updatedSettings); + obs_data_release(updatedSettings); + if (target <= lastTarget) + continue; + lastTarget = target; + const double rungProgress = + probePolicy::probeSubstepProgress(slotStartProgress, slotEndProgress, emittedRungCount, plannedTargets.size()); + emittedRungCount++; + pushEvent(session, "progress", "bandwidth", rungProgress, "youtube_probe_measuring", probe.legId, "active", probe.probeId, + probe.provider, (uint32_t)target); + blog(LOG_INFO, "[Auto Optimizer][YouTube Probe] Starting rung: ladder_target=%d Kbps, applied_video_target=%d Kbps", ladderTarget, + target); + + if (!waitForProbeInterval(session, resources.output, + std::chrono::steady_clock::now() + std::chrono::milliseconds(kYoutubeProbeSettleMs), result)) { + blog(LOG_WARNING, "[Auto Optimizer][YouTube Probe] Rung %d Kbps stopped during settle: %s", target, + result.errorCode.empty() ? (result.cancelled ? "cancelled" : "unknown") : result.errorCode.c_str()); + return result; + } + const uint64_t startBytes = obs_output_get_total_bytes(resources.output); + const uint32_t startDropped = obs_output_get_frames_dropped(resources.output); + const uint32_t startFrames = obs_output_get_total_frames(resources.output); + float maximumCongestion = 0.0f; + const auto sampleStart = std::chrono::steady_clock::now(); + const auto sampleDeadline = std::min(sampleStart + std::chrono::milliseconds(kYoutubeProbeSampleMs), youtubeDeadline); + while (std::chrono::steady_clock::now() < sampleDeadline) { + maximumCongestion = std::max(maximumCongestion, obs_output_get_congestion(resources.output)); + if (!waitForProbeInterval(session, resources.output, std::chrono::steady_clock::now() + std::chrono::milliseconds(50), + result)) { + blog(LOG_WARNING, "[Auto Optimizer][YouTube Probe] Rung %d Kbps stopped during measurement: %s", target, + result.errorCode.empty() ? (result.cancelled ? "cancelled" : "unknown") : result.errorCode.c_str()); + return result; + } + } + const auto sampleEnd = std::chrono::steady_clock::now(); + const uint64_t endBytes = obs_output_get_total_bytes(resources.output); + const uint32_t endDropped = obs_output_get_frames_dropped(resources.output); + const uint32_t endFrames = obs_output_get_total_frames(resources.output); + const uint64_t elapsedNs = (uint64_t)std::chrono::duration_cast(sampleEnd - sampleStart).count(); + if (endBytes <= startBytes || elapsedNs == 0) { + terminationReason = "no_sample_data"; + blog(LOG_WARNING, + "[Auto Optimizer][YouTube Probe] Rung %d Kbps produced no measurable data: start_bytes=%llu, end_bytes=%llu, elapsed_ns=%llu", + target, (unsigned long long)startBytes, (unsigned long long)endBytes, (unsigned long long)elapsedNs); + break; + } + const uint64_t measured = ((endBytes - startBytes) * 8ULL * 1000000000ULL) / elapsedNs / 1000ULL; + totalProbeBytes += endBytes - startBytes; + const uint32_t frameDelta = endFrames >= startFrames ? endFrames - startFrames : 0; + const uint32_t droppedDelta = endDropped >= startDropped ? endDropped - startDropped : 0; + const uint64_t expectedAggregateKbps = (uint64_t)target + kYoutubeProbeAudioBitrateKbps; + const bool throughputPassed = measured * 100ULL >= expectedAggregateKbps * 90ULL; + const bool dropsPassed = frameDelta == 0 || (uint64_t)droppedDelta * 100ULL <= (uint64_t)frameDelta * 2ULL; + const bool congestionPassed = maximumCongestion < 0.20f; + const bool rungPassed = throughputPassed && dropsPassed && congestionPassed; + const auto elapsedMs = std::chrono::duration_cast(sampleEnd - sampleStart).count(); + const uint64_t sampleBytes = endBytes - startBytes; + blog(LOG_INFO, + "[Auto Optimizer][YouTube Probe] Rung result: video_target=%d Kbps, expected_aggregate=%llu Kbps, " + "measured_aggregate=%llu Kbps, elapsed=%lld ms, sample_bytes=%llu, frames=%u, dropped=%u, max_congestion=%.3f, " + "throughput_passed=%s, drops_passed=%s, congestion_passed=%s, passed=%s", + target, (unsigned long long)expectedAggregateKbps, (unsigned long long)measured, (long long)elapsedMs, + (unsigned long long)sampleBytes, (unsigned int)frameDelta, (unsigned int)droppedDelta, (double)maximumCongestion, + throughputPassed ? "true" : "false", dropsPassed ? "true" : "false", congestionPassed ? "true" : "false", + rungPassed ? "true" : "false"); + if (!throughputPassed || !dropsPassed || !congestionPassed) { + terminationReason = "quality_gate_failed"; + // Even a failed first rung is useful evidence: it is a + // conservative upper bound and must not be discarded in + // favor of a higher pre-probe bitrate. + rampEvidence.observe(measured, false, expectedAggregateKbps); + result.measuredKbps = rampEvidence.recommendationBasisKbps; + break; + } + rampEvidence.observe(measured, true); + result.measuredKbps = rampEvidence.recommendationBasisKbps; + result.ceilingReached = probePolicy::reachedEffectiveProbeCeiling(target, effectiveCeilingKbps); + if (result.ceilingReached) + terminationReason = "effective_ceiling_reached"; + if (target < ladderTarget) { + terminationReason = "provider_or_request_cap_reached"; + break; + } + if (totalProbeBytes >= kYoutubeProbeMaxBytes) { + terminationReason = "byte_budget_reached"; + break; + } + } + if (result.measuredKbps == 0) { + blog(LOG_WARNING, "[Auto Optimizer][YouTube Probe] Ladder failed without usable throughput: termination=%s", terminationReason.c_str()); + result.errorCode = "youtube_probe_no_passing_step"; + return result; + } + const uint64_t uncappedSafeKbps = rampEvidence.safeVideoKbps(kYoutubeProbeSafeMultiplierPercent, kYoutubeProbeAudioBitrateKbps); + result.safeKbps = uncappedSafeKbps; + if (result.platformCapKbps > 0) + result.safeKbps = std::min(result.safeKbps, (uint64_t)result.platformCapKbps); + if (leg.limits.maxBitrateKbps > 0) + result.safeKbps = std::min(result.safeKbps, (uint64_t)leg.limits.maxBitrateKbps); + blog(rampEvidence.passedStep ? LOG_INFO : LOG_WARNING, + "[Auto Optimizer][YouTube Probe] Ladder summary: passed_step=%s, recommendation_basis=%llu Kbps, failed_upper_bound=%llu Kbps, " + "safe_multiplier=%d%%, audio_reserve=%d Kbps, uncapped_safe_video=%llu Kbps, final_safe_video=%llu Kbps, " + "platform_cap=%d Kbps, request_cap=%d Kbps, total_sample_bytes=%llu, ceiling_reached=%s, termination=%s", + rampEvidence.passedStep ? "true" : "false", (unsigned long long)rampEvidence.recommendationBasisKbps, + (unsigned long long)rampEvidence.failedUpperBoundKbps, kYoutubeProbeSafeMultiplierPercent, kYoutubeProbeAudioBitrateKbps, + (unsigned long long)uncappedSafeKbps, (unsigned long long)result.safeKbps, result.platformCapKbps, leg.limits.maxBitrateKbps, + (unsigned long long)totalProbeBytes, result.ceilingReached ? "true" : "false", terminationReason.c_str()); + if (!rampEvidence.passedStep) { + result.errorCode = "youtube_probe_no_passing_step"; + return result; + } + result.success = true; } - // Measure only the sample window. Connection/handshake latency is a separate - // diagnostic and must never be used as the throughput denominator. - result.measuredKbps = ((endBytes - startBytes) * 8ULL * 1000000000ULL) / elapsedNs / 1000ULL; obs_output_stop(resources.output); if (!waitForOutputInactive(resources.output, kProbeStopTimeoutMs)) { obs_output_force_stop(resources.output); if (!waitForOutputInactive(resources.output, kProbeStopTimeoutMs)) { - result.errorCode = "twitch_probe_cleanup_timeout"; + result.success = false; + result.errorCode = result.provider + "_probe_cleanup_timeout"; return result; } } - - result.success = result.measuredKbps > 0; return result; } +static void clearProbeSecrets(Session &session) +{ + for (auto &probe : session.probes) { + probe.streamKey.clear(); + probe.server.clear(); + } +} + static void completeCancelled(const std::shared_ptr &session) { - session->probe.streamKey.clear(); + clearProbeSecrets(*session); { std::lock_guard lock(session->mutex); session->resultJson = serializeResult(*session, "cancelled", {}, "cancelled"); @@ -1435,7 +1874,7 @@ static void completeCancelled(const std::shared_ptr &session) static void completeFailed(const std::shared_ptr &session, const char *code) { - session->probe.streamKey.clear(); + clearProbeSecrets(*session); { std::lock_guard lock(session->mutex); session->resultJson = serializeResult(*session, "failed", {}, code); @@ -1472,23 +1911,53 @@ static void runSession(const std::shared_ptr &session) pushEvent(session, "progress", "hardware", progress, code, preparedLegs.back().legId); } - ProbeResult probeResult; - bool usedActiveProbe = false; - if (session->activeProbeEligible) { - pushEvent(session, "phase", "bandwidth", 30, {}, session->legs[0].legId, "active"); - probeResult = runTwitchProbe(session, session->legs[0]); - if (probeResult.cancelled || session->cancelRequested.load()) { + std::vector probeResults; + const size_t eligibleProbeCount = + std::count_if(session->probes.begin(), session->probes.end(), [](const ProbeRequest &probe) { return probe.eligible; }); + size_t completedProbeCount = 0; + for (auto &probe : session->probes) { + if (!probe.eligible) { + pushEvent(session, "progress", "bandwidth", 30, probe.denialReason, probe.legId, "estimated", probe.probeId, probe.provider); + continue; + } + const auto legIt = std::find_if(preparedLegs.begin(), preparedLegs.end(), [&](const LegRequest &leg) { return leg.legId == probe.legId; }); + if (legIt == preparedLegs.end()) + continue; + const double startProgress = 30.0 + (35.0 * (double)completedProbeCount / (double)std::max(1, eligibleProbeCount)); + const double endProgress = 30.0 + (35.0 * (double)(completedProbeCount + 1) / (double)std::max(1, eligibleProbeCount)); + pushEvent(session, "phase", "bandwidth", startProgress, probe.provider + "_probe_started", probe.legId, "active", probe.probeId, + probe.provider); + const auto probeRunStarted = std::chrono::steady_clock::now(); + ProbeResult result = runRtmpProbe(session, probe, *legIt, startProgress, endProgress); + const auto probeRunElapsedMs = + std::chrono::duration_cast(std::chrono::steady_clock::now() - probeRunStarted).count(); + if (result.provider == "youtube") { + blog(result.success ? LOG_INFO : LOG_WARNING, + "[Auto Optimizer][YouTube Probe] Probe summary: success=%s, cancelled=%s, measured_aggregate=%llu Kbps, " + "safe_video=%llu Kbps, ceiling_reached=%s, elapsed=%lld ms, error=%s", + result.success ? "true" : "false", result.cancelled ? "true" : "false", (unsigned long long)result.measuredKbps, + (unsigned long long)result.safeKbps, result.ceilingReached ? "true" : "false", (long long)probeRunElapsedMs, + result.errorCode.empty() ? "none" : result.errorCode.c_str()); + } + if (probe.provider == "youtube") { + std::lock_guard lock(session->probeConfirmationMutex); + session->probeConfirmations.erase(probe.probeId); + if (session->activeConfirmationProbeId == probe.probeId) + session->activeConfirmationProbeId.clear(); + } + completedProbeCount++; + if (result.cancelled || session->cancelRequested.load()) { completeCancelled(session); return; } - usedActiveProbe = probeResult.success; - if (!probeResult.success) - pushEvent(session, "progress", "bandwidth", 65, "twitch_probe_failed_estimate_used", session->legs[0].legId, "estimated"); - } else { - session->probe.streamKey.clear(); - pushEvent(session, "progress", "bandwidth", 65, session->activeProbeDenialReason.empty() ? "estimate_only" : session->activeProbeDenialReason, - {}, "estimated"); + pushEvent(session, "progress", "bandwidth", endProgress, + result.success ? result.provider + "_probe_completed" : result.provider + "_probe_failed_estimate_used", result.legId, + result.success ? "active" : "estimated", probe.probeId, probe.provider); + probeResults.push_back(std::move(result)); } + clearProbeSecrets(*session); + if (eligibleProbeCount == 0) + pushEvent(session, "progress", "bandwidth", 65, "estimate_only", {}, "estimated"); if (session->cancelRequested.load()) { completeCancelled(session); @@ -1512,15 +1981,33 @@ static void runSession(const std::shared_ptr &session) recommendation.reason = hardware.reason; } - if (usedActiveProbe && leg.legId == session->probe.legId) { + const size_t requiredProbeCount = std::count_if(session->probes.begin(), session->probes.end(), + [&](const ProbeRequest &probe) { return probe.eligible && probe.legId == leg.legId; }); + std::vector legProbeResults; + for (const auto &probeResult : probeResults) { + if (probeResult.legId == leg.legId) + legProbeResults.push_back(&probeResult); + } + const bool allRequiredProbesPassed = + requiredProbeCount > 0 && legProbeResults.size() == requiredProbeCount && + std::all_of(legProbeResults.begin(), legProbeResults.end(), [](const ProbeResult *result) { return result->success; }); + for (const ProbeResult *result : legProbeResults) { + recommendation.probes.push_back({result->provider, result->method, result->measuredKbps, result->safeKbps, result->headroomPercent, + result->success, result->ceilingReached}); + } + + if (allRequiredProbesPassed) { recommendation.measurementMode = "active"; - if (hardware.passed && !hardware.constrained) { + if (hardware.passed && session->topology == "cloud-multistream") { + recommendation.confidence = "medium"; + recommendation.reason = "indirect_provider_probes"; + } else if (hardware.passed && !hardware.constrained) { recommendation.confidence = "high"; recommendation.reason.clear(); } - uint64_t safeKbps = probeResult.measuredKbps * 70ULL / 100ULL; - if (probeResult.platformCapKbps > 0) - safeKbps = std::min(safeKbps, (uint64_t)probeResult.platformCapKbps); + uint64_t safeKbps = UINT64_MAX; + for (const ProbeResult *result : legProbeResults) + safeKbps = std::min(safeKbps, result->safeKbps); if (leg.limits.maxBitrateKbps > 0) safeKbps = std::min(safeKbps, (uint64_t)leg.limits.maxBitrateKbps); // Never turn a low measurement into a higher recommendation merely to @@ -1530,10 +2017,28 @@ static void runSession(const std::shared_ptr &session) recommendation.confidence = "low"; recommendation.reason = "insufficient_bandwidth"; } - recommendation.value.bitrateKbps = (int)std::clamp(safeKbps, 1, kProbeMaximumBitrateKbps); - } else if (session->activeProbeEligible && leg.legId == session->probe.legId && !probeResult.success) { + recommendation.value.bitrateKbps = (int)std::clamp(safeKbps, 1, kYoutubeProbeMaximumBitrateKbps); + } else if (requiredProbeCount > 0) { recommendation.confidence = "low"; - recommendation.reason = "probe_failed"; + recommendation.reason = session->topology == "cloud-multistream" ? "indirect_provider_probe_failed" : "probe_failed"; + // A failed YouTube rung can still report observed throughput. + // Treat its safe value as an upper bound on the estimate so a + // failed low rung can never fall back to a higher current bitrate. + uint64_t observedSafeKbps = UINT64_MAX; + bool hasObservedThroughput = false; + for (const ProbeResult *result : legProbeResults) { + if (result->measuredKbps > 0) { + hasObservedThroughput = true; + observedSafeKbps = std::min(observedSafeKbps, result->safeKbps); + } + } + if (hasObservedThroughput) { + const uint64_t representableSafeKbps = std::max(1, observedSafeKbps); + recommendation.value.bitrateKbps = probePolicy::clampEstimateToObservedSafe( + recommendation.value.bitrateKbps, representableSafeKbps, kYoutubeProbeMaximumBitrateKbps); + if (observedSafeKbps < 500) + recommendation.reason = "insufficient_bandwidth"; + } } recommendations.push_back(std::move(recommendation)); @@ -1561,6 +2066,7 @@ static bool requestCancellation(const std::shared_ptr &session) return true; session->cancelRequested.store(true); + session->probeConfirmationCondition.notify_all(); { std::lock_guard lock(session->probeMutex); if (session->activeProbeOutput) @@ -1583,6 +2089,8 @@ void Register(ipc::server &srv) collection->register_function(std::make_shared("GetAutoConfigCapabilities", std::vector{}, GetCapabilities)); collection->register_function(std::make_shared("CreateAutoConfigSession", std::vector{ipc::type::String}, CreateSession)); collection->register_function(std::make_shared("StartAutoConfigSession", std::vector{ipc::type::String}, StartSession)); + collection->register_function(std::make_shared( + "ConfirmAutoConfigProbeIngest", std::vector{ipc::type::String, ipc::type::String, ipc::type::UInt32}, ConfirmProbeIngest)); collection->register_function(std::make_shared("QueryAutoConfigSession", std::vector{ipc::type::String}, QuerySession)); collection->register_function(std::make_shared("GetAutoConfigResult", std::vector{ipc::type::String}, GetResult)); collection->register_function(std::make_shared("CancelAutoConfigSession", std::vector{ipc::type::String}, CancelSession)); @@ -1594,7 +2102,7 @@ void Register(ipc::server &srv) void GetCapabilities(void *, const int64_t, const std::vector &, std::vector &rval) { static const char *capabilities = - R"({"apiVersion":2,"resultSchemaVersion":1,"previewApplySplit":true,"awaitableCancel":true,"perUploadLegResults":true,"desktopOwnedApply":true,"bandwidthModes":["twitch-standard-active","estimate"]})"; + R"({"apiVersion":2,"resultSchemaVersion":1,"previewApplySplit":true,"awaitableCancel":true,"perUploadLegResults":true,"desktopOwnedApply":true,"multipleActiveProbes":true,"bandwidthModes":["twitch-standard-active","youtube-unbound-active","estimate"]})"; rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); rval.push_back(ipc::value(capabilities)); } @@ -1668,6 +2176,42 @@ void StartSession(void *, const int64_t, const std::vector &args, st rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); } +void ConfirmProbeIngest(void *, const int64_t, const std::vector &args, std::vector &rval) +{ + if (args.size() != 3) { + returnError(rval, "ConfirmAutoConfigProbeIngest expects sessionId, probeId, and received"); + return; + } + auto session = findSession(args[0].value_str); + if (!session) { + returnError(rval, "autoconfig_session_not_found"); + return; + } + if (session->state.load() != SessionState::Running) { + returnError(rval, "autoconfig_session_not_running"); + return; + } + const std::string &probeId = args[1].value_str; + const auto probe = std::find_if(session->probes.begin(), session->probes.end(), [&](const ProbeRequest &candidate) { + return candidate.eligible && candidate.provider == "youtube" && candidate.probeId == probeId; + }); + if (probe == session->probes.end()) { + returnError(rval, "autoconfig_probe_not_found"); + return; + } + { + std::lock_guard lock(session->probeConfirmationMutex); + auto confirmation = session->probeConfirmations.find(probeId); + if (confirmation == session->probeConfirmations.end() || session->activeConfirmationProbeId != probeId) { + returnError(rval, "autoconfig_probe_not_confirmable"); + return; + } + confirmation->second = args[2].value_union.ui32 ? 1 : -1; + } + session->probeConfirmationCondition.notify_all(); + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); +} + void QuerySession(void *, const int64_t, const std::vector &args, std::vector &rval) { if (args.size() != 1) { @@ -1695,6 +2239,9 @@ void QuerySession(void *, const int64_t, const std::vector &args, st rval.push_back(ipc::value(event.code)); rval.push_back(ipc::value(event.legId)); rval.push_back(ipc::value(event.measurementMode)); + rval.push_back(ipc::value(event.probeId)); + rval.push_back(ipc::value(event.provider)); + rval.push_back(ipc::value(event.targetBitrateKbps)); session->events.pop(); } @@ -1785,6 +2332,7 @@ void Shutdown() completeCancelled(session); } else if (state == SessionState::Running) { session->cancelRequested.store(true); + session->probeConfirmationCondition.notify_all(); { std::lock_guard lock(session->probeMutex); if (session->activeProbeOutput) @@ -1798,7 +2346,7 @@ void Shutdown() // normal cancellation deadline. if (session->worker.valid()) session->worker.wait(); - session->probe.streamKey.clear(); + clearProbeSecrets(*session); session->state.store(SessionState::Closed); } diff --git a/obs-studio-server/source/nodeobs_autoconfig.h b/obs-studio-server/source/nodeobs_autoconfig.h index cdfdca4f0..c051b5aeb 100644 --- a/obs-studio-server/source/nodeobs_autoconfig.h +++ b/obs-studio-server/source/nodeobs_autoconfig.h @@ -19,6 +19,7 @@ void Shutdown(); void GetCapabilities(void *data, const int64_t id, const std::vector &args, std::vector &rval); void CreateSession(void *data, const int64_t id, const std::vector &args, std::vector &rval); void StartSession(void *data, const int64_t id, const std::vector &args, std::vector &rval); +void ConfirmProbeIngest(void *data, const int64_t id, const std::vector &args, std::vector &rval); void QuerySession(void *data, const int64_t id, const std::vector &args, std::vector &rval); void GetResult(void *data, const int64_t id, const std::vector &args, std::vector &rval); void CancelSession(void *data, const int64_t id, const std::vector &args, std::vector &rval); diff --git a/source/autoconfig-probe-policy.hpp b/source/autoconfig-probe-policy.hpp new file mode 100644 index 000000000..ab530319e --- /dev/null +++ b/source/autoconfig-probe-policy.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include + +namespace autoConfig { +namespace probePolicy { + +struct YoutubeRampEvidence { + bool passedStep = false; + uint64_t recommendationBasisKbps = 0; + uint64_t failedUpperBoundKbps = 0; + + void observe(uint64_t measuredKbps, bool passed, uint64_t attemptedAggregateKbps = 0) + { + if (passed) { + passedStep = true; + recommendationBasisKbps = measuredKbps; + return; + } + + failedUpperBoundKbps = attemptedAggregateKbps > 0 ? std::min(measuredKbps, attemptedAggregateKbps) : measuredKbps; + if (!passedStep) + recommendationBasisKbps = measuredKbps; + } + + uint64_t safeVideoKbps(int safeMultiplierPercent, uint64_t audioKbps) const + { + if (recommendationBasisKbps == 0 || safeMultiplierPercent <= 0) + return 0; + + uint64_t safe = std::max(1, recommendationBasisKbps * (uint64_t)safeMultiplierPercent / 100ULL); + if (failedUpperBoundKbps > 0) { + const uint64_t failedSafe = std::max(1, failedUpperBoundKbps * (uint64_t)safeMultiplierPercent / 100ULL); + safe = std::min(safe, failedSafe); + } + return safe > audioKbps ? safe - audioKbps : 0; + } +}; + +inline uint64_t safeVideoKbps(uint64_t measuredAggregateKbps, int safeMultiplierPercent, uint64_t audioKbps) +{ + if (measuredAggregateKbps == 0 || safeMultiplierPercent <= 0) + return 0; + const uint64_t safeAggregateKbps = measuredAggregateKbps * (uint64_t)safeMultiplierPercent / 100ULL; + return safeAggregateKbps > audioKbps ? safeAggregateKbps - audioKbps : 0; +} + +inline bool hasProbeThroughputMetrics(bool success, uint64_t measuredKbps) +{ + return success || measuredKbps > 0; +} + +inline int clampEstimateToObservedSafe(int estimatedKbps, uint64_t observedSafeKbps, int maximumKbps) +{ + if (observedSafeKbps == 0 || maximumKbps <= 0) + return estimatedKbps; + return std::min(estimatedKbps, (int)std::min(observedSafeKbps, (uint64_t)maximumKbps)); +} + +inline int effectiveProbeCeilingKbps(int probeMaximumKbps, int platformMaximumKbps, int requestMaximumKbps) +{ + int effective = probeMaximumKbps; + if (platformMaximumKbps > 0) + effective = std::min(effective, platformMaximumKbps); + if (requestMaximumKbps > 0) + effective = std::min(effective, requestMaximumKbps); + return effective; +} + +inline bool reachedEffectiveProbeCeiling(int targetKbps, int effectiveCeilingKbps) +{ + return targetKbps > 0 && effectiveCeilingKbps > 0 && targetKbps >= effectiveCeilingKbps; +} + +inline double probeSubstepProgress(double slotStart, double slotEnd, size_t stepIndex, size_t stepCount) +{ + if (stepCount == 0 || slotEnd <= slotStart) + return slotStart; + + const size_t clampedIndex = std::min(stepIndex, stepCount - 1); + return slotStart + (slotEnd - slotStart) * (double)(clampedIndex + 1) / (double)(stepCount + 1); +} + +} // namespace probePolicy +} // namespace autoConfig diff --git a/tests/osn-tests/src/test_osn_auto_optimizer_v1.ts b/tests/osn-tests/src/test_osn_auto_optimizer_v1.ts index e0d7f31b8..65b3735d1 100644 --- a/tests/osn-tests/src/test_osn_auto_optimizer_v1.ts +++ b/tests/osn-tests/src/test_osn_auto_optimizer_v1.ts @@ -16,7 +16,7 @@ const testName = 'osn-auto-optimizer-v1'; const mockPort = 11937; describe(testName, function() { - this.timeout(30000); + this.timeout(80000); let obs: OBSHandler; @@ -85,7 +85,7 @@ describe(testName, function() { try { osn.NodeObs.CloseAutoConfigSession(sessionId); } catch (_) { /* best effort */ } } reject(new Error('Auto Optimizer session timed out')); - }, 15000); + }, 60000); const onEvent = (event: IAutoConfigEvent) => { events.push(event); @@ -114,6 +114,7 @@ describe(testName, function() { } it('advertises the versioned, Desktop-owned apply contract', function() { + expect(osn.NodeObs.ConfirmAutoConfigProbeIngest).to.be.a('function'); const capabilities = JSON.parse(osn.NodeObs.GetAutoConfigCapabilities()) as IAutoConfigCapabilities; expect(capabilities).to.deep.equal({ apiVersion: 2, @@ -122,7 +123,8 @@ describe(testName, function() { awaitableCancel: true, perUploadLegResults: true, desktopOwnedApply: true, - bandwidthModes: ['twitch-standard-active', 'estimate'], + multipleActiveProbes: true, + bandwidthModes: ['twitch-standard-active', 'youtube-unbound-active', 'estimate'], }); }); @@ -134,13 +136,14 @@ describe(testName, function() { schemaVersion: 1, topology: 'custom-rtmp', legs: [leg()], - activeProbe: { + activeProbes: [{ + probeId: 'twitch-primary', kind: 'twitch-standard-v1', legId: 'primary', serviceName: 'Twitch', server: `rtmp://127.0.0.1:${mockPort}/live`, streamKey: secret, - }, + }], }); expect(mock.getConnections()).to.equal(0); @@ -157,6 +160,135 @@ describe(testName, function() { } }); + it('default-denies non-official YouTube probe endpoints without dialing them', async function() { + const mock = await startConnectionSink(mockPort); + const secret = 'youtube-secret-must-not-appear'; + try { + const response = await run({ + schemaVersion: 1, + topology: 'direct-single', + legs: [leg({ destinations: [{ platform: 'youtube' }] })], + activeProbes: [{ + probeId: 'youtube-primary', + kind: 'youtube-unbound-v1', + legId: 'primary', + serviceName: 'YouTube - RTMPS', + server: `rtmps://127.0.0.1:${mockPort}/live2`, + streamKey: secret, + }], + }); + + expect(mock.getConnections()).to.equal(0); + expect(mock.getBytes()).to.equal(0); + expect(response.result.legs[0].measurement.mode).to.equal('estimated'); + expect(JSON.stringify(response.result)).not.to.contain(secret); + expect(JSON.stringify(response.events)).not.to.contain(secret); + expect(response.events.some(event => event.code === 'active_probe_not_eligible')).to.equal(true); + } finally { + await mock.close(); + } + }); + + it('requires the exact YouTube RTMPS service identity before a probe is eligible', async function() { + const request = { + schemaVersion: 1, + topology: 'direct-single', + legs: [leg({ destinations: [{ platform: 'youtube' }] })], + activeProbes: [{ + probeId: 'youtube-missing-service', + kind: 'youtube-unbound-v1', + legId: 'primary', + server: 'rtmps://a.rtmps.youtube.com/live2', + streamKey: 'not-a-real-key', + }], + } as unknown as IAutoConfigRequest; + + const response = await run(request); + expect(response.result.legs[0].measurement.mode).to.equal('estimated'); + expect(response.events.some(event => event.code === 'active_probe_not_eligible')).to.equal(true); + expect(response.events.some(event => event.code === 'youtube_probe_started')).to.equal(false); + }); + + it('requires a complete Twitch and YouTube probe set for a shared cloud leg', async function() { + const response = await run({ + schemaVersion: 1, + topology: 'cloud-multistream', + legs: [leg({ + destinations: [{ platform: 'twitch' }, { platform: 'youtube' }], + estimateReason: 'cloud_multistream', + })], + activeProbes: [{ + probeId: 'cloud-twitch-only', + kind: 'twitch-standard-v1', + legId: 'primary', + serviceName: 'Twitch', + // The incomplete set must be rejected before any connection + // attempt, so an intentionally unofficial endpoint is safe. + server: `rtmp://127.0.0.1:${mockPort}/live`, + streamKey: 'incomplete-cloud-secret', + }], + }); + expect(response.result.legs[0].measurement.mode).to.equal('estimated'); + expect(response.result.legs[0].measurement.reason).to.equal('cloud_multistream'); + expect(response.events.some(event => event.code === 'active_probe_set_incomplete')).to.equal(true); + expect(response.events.some(event => event.code === 'twitch_probe_started')).to.equal(false); + expect(JSON.stringify(response.result)).not.to.contain('incomplete-cloud-secret'); + expect(JSON.stringify(response.events)).not.to.contain('incomplete-cloud-secret'); + }); + + it('default-denies independent dual-output active probes instead of recommending full uplink per leg', async function() { + const twitchSecret = 'dual-twitch-secret'; + const youtubeSecret = 'dual-youtube-secret'; + const response = await run({ + schemaVersion: 1, + topology: 'dual-output', + legs: [ + leg({ + legId: 'horizontal', + display: 'horizontal', + destinations: [{ platform: 'twitch' }], + current: { ...leg().current, bitrateKbps: 6000 }, + estimateReason: 'dual_output', + }), + leg({ + legId: 'vertical', + display: 'vertical', + destinations: [{ platform: 'youtube' }], + current: { ...leg().current, bitrateKbps: 6000 }, + estimateReason: 'dual_output', + }), + ], + activeProbes: [ + { + probeId: 'dual-twitch', + kind: 'twitch-standard-v1', + legId: 'horizontal', + serviceName: 'Twitch', + server: 'rtmp://live.twitch.tv/app', + streamKey: twitchSecret, + }, + { + probeId: 'dual-youtube', + kind: 'youtube-unbound-v1', + legId: 'vertical', + serviceName: 'YouTube - RTMPS', + server: 'rtmps://a.rtmps.youtube.com/live2', + streamKey: youtubeSecret, + }, + ], + }); + + expect(response.result.legs).to.have.length(2); + expect(response.result.legs.every(resultLeg => resultLeg.measurement.mode === 'estimated')).to.equal(true); + expect(response.result.legs.every(resultLeg => resultLeg.measurement.reason === 'dual_output')).to.equal(true); + expect(response.events.filter(event => event.code === 'dual_output_multiple_active_legs')).to.have.length(2); + expect(response.events.some(event => event.code === 'twitch_probe_started' || event.code === 'youtube_probe_started')).to.equal(false); + expect(JSON.stringify(response.result)).not.to.contain(twitchSecret); + expect(JSON.stringify(response.result)).not.to.contain(youtubeSecret); + expect(JSON.stringify(response.events)).not.to.contain(twitchSecret); + expect(JSON.stringify(response.events)).not.to.contain(youtubeSecret); + }); + it('clamps estimate-only results to bundled platform caps without raising current bitrate', async function() { const twitch = await run({ schemaVersion: 1, From 72dd26a408d55459c324020333017dac30101521 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Fri, 17 Jul 2026 23:29:11 +0100 Subject: [PATCH 6/7] Fix generated JS CI inputs --- js/module.d.ts | 1 - yarn.lock | 30 +++++++++++++++--------------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/js/module.d.ts b/js/module.d.ts index 4720791ce..253eef3c5 100644 --- a/js/module.d.ts +++ b/js/module.d.ts @@ -1069,7 +1069,6 @@ export interface IAutoConfigEvent { measurementMode?: AutoConfigMeasurementMode; probeId?: string; provider?: 'twitch' | 'youtube'; - /** Applied video bitrate for the active probe substep; audio is additional. */ targetBitrateKbps?: number; } export interface IAutoConfigProbeMeasurement { diff --git a/yarn.lock b/yarn.lock index b3824dc18..4375b7765 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1920,7 +1920,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.1.0": +"chalk@npm:^4.1.0": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -2104,15 +2104,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:^2.2.0": - version: 2.6.9 - resolution: "debug@npm:2.6.9" - dependencies: - ms: "npm:2.0.0" - checksum: 10c0/121908fb839f7801180b69a7e218a40b5a0b718813b886b7d6bdb82001b931c938e2941d1e4450f33a1b1df1da653f5f7a0440c197f29fbf8a6e9d45ff6ef589 - languageName: node - linkType: hard - +"debug@npm:^2.2.0": + version: 2.6.9 + resolution: "debug@npm:2.6.9" + dependencies: + ms: "npm:2.0.0" + checksum: 10c0/121908fb839f7801180b69a7e218a40b5a0b718813b886b7d6bdb82001b931c938e2941d1e4450f33a1b1df1da653f5f7a0440c197f29fbf8a6e9d45ff6ef589 + languageName: node + linkType: hard + "debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.5": version: 4.4.3 resolution: "debug@npm:4.4.3" @@ -2295,7 +2295,7 @@ __metadata: languageName: node linkType: hard -"es-define-property@npm:^1.0.0": +"es-define-property@npm:^1.0.0": version: 1.0.1 resolution: "es-define-property@npm:1.0.1" checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c @@ -2595,7 +2595,7 @@ __metadata: languageName: node linkType: hard -"gopd@npm:^1.0.1": +"gopd@npm:^1.0.1": version: 1.2.0 resolution: "gopd@npm:1.2.0" checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead @@ -2900,7 +2900,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.15": +"lodash@npm:^4.17.15": version: 4.18.1 resolution: "lodash@npm:4.18.1" checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27 @@ -3047,7 +3047,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0, minimist@npm:^1.2.6": +"minimist@npm:^1.2.0, minimist@npm:^1.2.6": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 @@ -3510,7 +3510,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 From 8f4093f0c2ae8b7c6279502cb13e0fcd1296e06f Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Wed, 5 Aug 2026 13:03:32 +1200 Subject: [PATCH 7/7] Improve YouTube bandwidth probe stability detection --- js/module.d.ts | 6 +- js/module.ts | 8 +- .../tests/autoconfig-probe-policy-test.cpp | 112 +++ .../source/nodeobs_autoconfig.cpp | 722 ++++++++++++++---- source/autoconfig-probe-policy.hpp | 157 ++++ ...mizer_v1.ts => test_osn_auto_optimizer.ts} | 14 +- 6 files changed, 858 insertions(+), 161 deletions(-) rename tests/osn-tests/src/{test_osn_auto_optimizer_v1.ts => test_osn_auto_optimizer.ts} (98%) diff --git a/js/module.d.ts b/js/module.d.ts index 253eef3c5..d3c5847a1 100644 --- a/js/module.d.ts +++ b/js/module.d.ts @@ -1033,7 +1033,7 @@ export interface IAutoConfigLegRequest { } export interface IAutoConfigTwitchActiveProbe { probeId: string; - kind: 'twitch-standard-v1'; + kind: 'twitch-standard'; legId: string; serviceName: 'Twitch'; server: string; @@ -1041,7 +1041,7 @@ export interface IAutoConfigTwitchActiveProbe { } export interface IAutoConfigYoutubeActiveProbe { probeId: string; - kind: 'youtube-unbound-v1'; + kind: 'youtube-unbound'; legId: string; serviceName: 'YouTube - RTMPS'; server: string; @@ -1073,7 +1073,7 @@ export interface IAutoConfigEvent { } export interface IAutoConfigProbeMeasurement { provider: 'twitch' | 'youtube'; - method: 'twitch-bandwidth-test-v1' | 'youtube-unbound-ramp-v1'; + method: 'twitch-bandwidth-test' | 'youtube-unbound-ramp'; success: boolean; measuredKbps?: number; safeKbps?: number; diff --git a/js/module.ts b/js/module.ts index 8ec11b28b..d93267b4a 100644 --- a/js/module.ts +++ b/js/module.ts @@ -1961,7 +1961,7 @@ export interface IAudioTrackFactory { saveLegacySettings(): void; } -// ---- Auto Optimizer API v1 (native API version 2) ---- +// ---- Auto Optimizer API ---- export interface IAutoConfigCapabilities { apiVersion: 2; @@ -2038,7 +2038,7 @@ export interface IAutoConfigLegRequest { export interface IAutoConfigTwitchActiveProbe { probeId: string; - kind: 'twitch-standard-v1'; + kind: 'twitch-standard'; legId: string; serviceName: 'Twitch'; server: string; @@ -2054,7 +2054,7 @@ export interface IAutoConfigYoutubeActiveProbe { * before deleting the liveStream through the YouTube API. */ probeId: string; - kind: 'youtube-unbound-v1'; + kind: 'youtube-unbound'; legId: string; serviceName: 'YouTube - RTMPS'; server: string; @@ -2092,7 +2092,7 @@ export interface IAutoConfigEvent { export interface IAutoConfigProbeMeasurement { provider: 'twitch' | 'youtube'; - method: 'twitch-bandwidth-test-v1' | 'youtube-unbound-ramp-v1'; + method: 'twitch-bandwidth-test' | 'youtube-unbound-ramp'; success: boolean; /** Observed aggregate RTMP throughput, including audio. */ measuredKbps?: number; diff --git a/obs-studio-client/tests/autoconfig-probe-policy-test.cpp b/obs-studio-client/tests/autoconfig-probe-policy-test.cpp index 4435e79d6..ec4515e93 100644 --- a/obs-studio-client/tests/autoconfig-probe-policy-test.cpp +++ b/obs-studio-client/tests/autoconfig-probe-policy-test.cpp @@ -3,12 +3,124 @@ #include using autoConfig::probePolicy::YoutubeRampEvidence; +using autoConfig::probePolicy::YoutubeBaselineAssessment; +using autoConfig::probePolicy::YoutubeBaselineDecision; +using autoConfig::probePolicy::YoutubeConfirmationDecision; +using autoConfig::probePolicy::YoutubeProbeSampleClass; +using autoConfig::probePolicy::YoutubeProbeSampleMetrics; +using autoConfig::probePolicy::assessYoutubeBaseline; +using autoConfig::probePolicy::classifyYoutubeProbeSample; using autoConfig::probePolicy::clampEstimateToObservedSafe; +using autoConfig::probePolicy::decideYoutubeConfirmation; using autoConfig::probePolicy::effectiveProbeCeilingKbps; using autoConfig::probePolicy::hasProbeThroughputMetrics; +using autoConfig::probePolicy::makeYoutubeProbeSampleMetrics; using autoConfig::probePolicy::probeSubstepProgress; using autoConfig::probePolicy::reachedEffectiveProbeCeiling; +using autoConfig::probePolicy::resolveYoutubeBaseline; using autoConfig::probePolicy::safeVideoKbps; +using autoConfig::probePolicy::youtubeLowControlRecovered; +using autoConfig::probePolicy::youtubeSampleAccepted; + +TEST_CASE("YouTube probe metrics use deterministic basis-point ratios") +{ + const YoutubeProbeSampleMetrics sample = makeYoutubeProbeSampleMetrics(900, 1000, 3, 150, 10, 2, 100); + + CHECK(sample.throughputBasisPoints == 9000); + CHECK(sample.dropBasisPoints == 200); + CHECK(sample.congestionHighBasisPoints == 1000); + CHECK(sample.congestionSevereBasisPoints == 200); + CHECK(classifyYoutubeProbeSample(sample) == YoutubeProbeSampleClass::Clean); +} + +TEST_CASE("YouTube probe sample classification honors clean and hard boundaries") +{ + CHECK(classifyYoutubeProbeSample({9000, 200, 1000, 200}) == YoutubeProbeSampleClass::Clean); + CHECK(classifyYoutubeProbeSample({8999, 200, 1000, 200}) == YoutubeProbeSampleClass::Marginal); + CHECK(classifyYoutubeProbeSample({7500, 500, 3000, 1000}) == YoutubeProbeSampleClass::Marginal); + CHECK(classifyYoutubeProbeSample({7499, 0, 0, 0}) == YoutubeProbeSampleClass::Hard); + CHECK(classifyYoutubeProbeSample({10000, 501, 0, 0}) == YoutubeProbeSampleClass::Hard); + CHECK(classifyYoutubeProbeSample({10000, 0, 3001, 0}) == YoutubeProbeSampleClass::Hard); + CHECK(classifyYoutubeProbeSample({10000, 0, 0, 1001}) == YoutubeProbeSampleClass::Hard); +} + +TEST_CASE("YouTube probe ignores isolated congestion but rejects sustained congestion") +{ + const YoutubeProbeSampleMetrics isolatedSpike = makeYoutubeProbeSampleMetrics(950, 1000, 0, 150, 1, 0, 100); + const YoutubeProbeSampleMetrics sustainedCongestion = makeYoutubeProbeSampleMetrics(950, 1000, 0, 150, 31, 0, 100); + + CHECK(classifyYoutubeProbeSample(isolatedSpike) == YoutubeProbeSampleClass::Clean); + CHECK(classifyYoutubeProbeSample(sustainedCongestion) == YoutubeProbeSampleClass::Hard); +} + +TEST_CASE("Two YouTube baseline samples select every initial decision") +{ + const YoutubeProbeSampleMetrics cleanA{9500, 100, 500, 100}; + const YoutubeProbeSampleMetrics cleanB{9300, 150, 700, 150}; + const YoutubeProbeSampleMetrics marginalA{8500, 300, 1500, 300}; + const YoutubeProbeSampleMetrics marginalB{8200, 350, 2000, 400}; + const YoutubeProbeSampleMetrics hardA{7000, 300, 1500, 300}; + const YoutubeProbeSampleMetrics hardB{8000, 600, 1500, 300}; + + CHECK(assessYoutubeBaseline(cleanA, cleanB).decision == YoutubeBaselineDecision::Clean); + CHECK(assessYoutubeBaseline(cleanA, {10500, 200, 1000, 200}).decision == YoutubeBaselineDecision::NeedsThird); + CHECK(assessYoutubeBaseline(marginalA, marginalB).decision == YoutubeBaselineDecision::Impaired); + CHECK(assessYoutubeBaseline(cleanA, marginalA).decision == YoutubeBaselineDecision::NeedsThird); + CHECK(assessYoutubeBaseline(hardA, hardB).decision == YoutubeBaselineDecision::Unstable); +} + +TEST_CASE("A third YouTube baseline sample resolves from component medians") +{ + const YoutubeProbeSampleMetrics clean{9500, 100, 500, 100}; + const YoutubeProbeSampleMetrics marginal{8500, 300, 1500, 300}; + const YoutubeProbeSampleMetrics hard{6000, 700, 4000, 1500}; + + const YoutubeBaselineAssessment impaired = resolveYoutubeBaseline(clean, hard, marginal); + CHECK(impaired.decision == YoutubeBaselineDecision::Impaired); + CHECK(impaired.reference.throughputBasisPoints == marginal.throughputBasisPoints); + CHECK(impaired.reference.dropBasisPoints == marginal.dropBasisPoints); + CHECK(impaired.reference.congestionHighBasisPoints == marginal.congestionHighBasisPoints); + CHECK(impaired.reference.congestionSevereBasisPoints == marginal.congestionSevereBasisPoints); + + CHECK(resolveYoutubeBaseline(clean, hard, clean).decision == YoutubeBaselineDecision::Clean); + CHECK(resolveYoutubeBaseline(hard, clean, hard).decision == YoutubeBaselineDecision::Unstable); + CHECK(resolveYoutubeBaseline({7000, 100, 500, 100}, clean, {9500, 600, 500, 100}).decision == YoutubeBaselineDecision::Unstable); +} + +TEST_CASE("Impaired YouTube baseline accepts only bounded relative degradation") +{ + const YoutubeBaselineAssessment baseline{YoutubeBaselineDecision::Impaired, {8500, 300, 1500, 300}}; + + CHECK(youtubeSampleAccepted({8000, 400, 2500, 800}, baseline)); + CHECK_FALSE(youtubeSampleAccepted({7999, 400, 2500, 800}, baseline)); + CHECK_FALSE(youtubeSampleAccepted({8000, 401, 2500, 800}, baseline)); + CHECK_FALSE(youtubeSampleAccepted({8000, 400, 2501, 800}, baseline)); + CHECK_FALSE(youtubeSampleAccepted({8000, 400, 2500, 801}, baseline)); + + const YoutubeBaselineAssessment cleanBaseline{YoutubeBaselineDecision::Clean, {9500, 100, 500, 100}}; + CHECK(youtubeSampleAccepted({9200, 100, 500, 100}, cleanBaseline)); + CHECK_FALSE(youtubeSampleAccepted({8500, 100, 500, 100}, cleanBaseline)); +} + +TEST_CASE("YouTube low control must recover acceptance and the previous rung") +{ + const YoutubeBaselineAssessment baseline{YoutubeBaselineDecision::Impaired, {8500, 300, 1500, 300}}; + const YoutubeProbeSampleMetrics original{8800, 300, 1000, 200}; + + CHECK(youtubeLowControlRecovered({8300, 400, 2000, 700}, original, baseline)); + CHECK_FALSE(youtubeLowControlRecovered({8299, 400, 2000, 700}, original, baseline)); + CHECK_FALSE(youtubeLowControlRecovered({8300, 401, 2000, 700}, original, baseline)); + CHECK_FALSE(youtubeLowControlRecovered({8300, 400, 2001, 700}, original, baseline)); + CHECK_FALSE(youtubeLowControlRecovered({8300, 400, 2000, 701}, original, baseline)); +} + +TEST_CASE("YouTube high-low-high confirmation distinguishes all four outcomes") +{ + CHECK(decideYoutubeConfirmation(true, false) == YoutubeConfirmationDecision::CapacityKnee); + CHECK(decideYoutubeConfirmation(true, true) == YoutubeConfirmationDecision::TransientRecovered); + CHECK(decideYoutubeConfirmation(false, false) == YoutubeConfirmationDecision::PathUnstable); + CHECK(decideYoutubeConfirmation(false, true) == YoutubeConfirmationDecision::Inconsistent); +} TEST_CASE("YouTube first-rung failure retains a conservative observed cap") { diff --git a/obs-studio-server/source/nodeobs_autoconfig.cpp b/obs-studio-server/source/nodeobs_autoconfig.cpp index 9ceabf04c..9e4f26096 100644 --- a/obs-studio-server/source/nodeobs_autoconfig.cpp +++ b/obs-studio-server/source/nodeobs_autoconfig.cpp @@ -54,7 +54,13 @@ constexpr int kYoutubeProbeMaximumBitrateKbps = 12000; constexpr int kYoutubeProbeInitialBitrateKbps = 1000; constexpr int kYoutubeProbeSettleMs = 500; constexpr int kYoutubeProbeSampleMs = 5000; +constexpr int kYoutubeProbeSubwindowMs = 1000; constexpr int kYoutubeProbeTotalTimeoutMs = 100000; +constexpr int kYoutubeProbeBudgetSlackMs = 250; +constexpr int kYoutubeProbeMaximumConfirmationEpisodes = 2; +constexpr int kYoutubeProbeBudgetEstimatePercent = 115; +constexpr float kYoutubeProbeCongestionHigh = 0.20f; +constexpr float kYoutubeProbeCongestionSevere = 0.50f; constexpr int kTwitchProbeSafeMultiplierPercent = 70; constexpr int kYoutubeProbeSafeMultiplierPercent = 80; constexpr int kTwitchProbeAudioBitrateKbps = 32; @@ -488,9 +494,9 @@ static bool parseRequest(const std::string &json, Session &session, std::string valid = false; break; } - if (probe.kind == "twitch-standard-v1") + if (probe.kind == "twitch-standard") probe.provider = "twitch"; - else if (probe.kind == "youtube-unbound-v1") + else if (probe.kind == "youtube-unbound") probe.provider = "youtube"; session.probes.push_back(std::move(probe)); } @@ -876,6 +882,23 @@ static std::string serializeResult(const Session &session, const char *status, c return json; } +enum class ProbeStability { Stable, Degraded, Variable, Unstable }; + +static const char *probeStabilityName(ProbeStability stability) +{ + switch (stability) { + case ProbeStability::Stable: + return "stable"; + case ProbeStability::Degraded: + return "degraded"; + case ProbeStability::Variable: + return "variable"; + case ProbeStability::Unstable: + return "unstable"; + } + return "unknown"; +} + struct ProbeResult { bool success = false; bool cancelled = false; @@ -888,6 +911,8 @@ struct ProbeResult { std::string method; std::string legId; std::string errorCode; + ProbeStability stability = ProbeStability::Stable; + bool observedThroughputReliable = true; }; static bool silentAudioCallback(void *, uint64_t startTimestamp, uint64_t, uint64_t *outputTimestamp, uint32_t, struct audio_data_mixes_outputs *) @@ -1523,13 +1548,289 @@ static bool waitForYoutubeIngestConfirmation(const std::shared_ptr &ses return true; } +struct YoutubeProbeSample { + int targetVideoKbps = 0; + uint64_t expectedAggregateKbps = 0; + uint64_t measuredAggregateKbps = 0; + uint64_t medianSubwindowAggregateKbps = 0; + uint64_t wholeWindowAggregateKbps = 0; + uint64_t sampleBytes = 0; + uint32_t frames = 0; + uint32_t droppedFrames = 0; + uint32_t congestionSamples = 0; + uint32_t congestionHighSamples = 0; + uint32_t congestionSevereSamples = 0; + float maximumCongestion = 0.0f; + float p95Congestion = 0.0f; + long long elapsedMs = 0; + probePolicy::YoutubeProbeSampleMetrics metrics; +}; + +static const char *youtubeSampleClassName(probePolicy::YoutubeProbeSampleClass sampleClass) +{ + switch (sampleClass) { + case probePolicy::YoutubeProbeSampleClass::Clean: + return "clean"; + case probePolicy::YoutubeProbeSampleClass::Marginal: + return "marginal"; + case probePolicy::YoutubeProbeSampleClass::Hard: + return "hard"; + } + return "unknown"; +} + +static const char *youtubeBaselineDecisionName(probePolicy::YoutubeBaselineDecision decision) +{ + switch (decision) { + case probePolicy::YoutubeBaselineDecision::Clean: + return "clean"; + case probePolicy::YoutubeBaselineDecision::Impaired: + return "impaired"; + case probePolicy::YoutubeBaselineDecision::NeedsThird: + return "needs_third"; + case probePolicy::YoutubeBaselineDecision::Unstable: + return "unstable"; + } + return "unknown"; +} + +static const char *youtubeConfirmationDecisionName(probePolicy::YoutubeConfirmationDecision decision) +{ + switch (decision) { + case probePolicy::YoutubeConfirmationDecision::CapacityKnee: + return "capacity_knee"; + case probePolicy::YoutubeConfirmationDecision::TransientRecovered: + return "transient_recovered"; + case probePolicy::YoutubeConfirmationDecision::PathUnstable: + return "path_unstable"; + case probePolicy::YoutubeConfirmationDecision::Inconsistent: + return "inconsistent"; + } + return "unknown"; +} + +static uint64_t medianValue(std::vector values) +{ + if (values.empty()) + return 0; + std::sort(values.begin(), values.end()); + return values[(values.size() - 1) / 2]; +} + +static double youtubeProbeStepProgress(double slotStart, double slotEnd, size_t targetIndex, size_t targetCount, double fraction) +{ + const double measurementStart = std::min(slotStart + 2.0, slotEnd); + if (targetCount == 0 || slotEnd <= measurementStart) + return slotEnd; + const double position = ((double)std::min(targetIndex, targetCount - 1) + std::clamp(fraction, 0.0, 0.99)) / (double)targetCount; + return measurementStart + (slotEnd - measurementStart) * position; +} + +static uint64_t estimatedYoutubeSampleBytes(int targetVideoKbps, int durationMs) +{ + if (targetVideoKbps <= 0 || durationMs <= 0) + return 0; + const uint64_t aggregateKbps = (uint64_t)targetVideoKbps + kYoutubeProbeAudioBitrateKbps; + const uint64_t payloadBytes = aggregateKbps * (uint64_t)durationMs / 8ULL; + return payloadBytes * kYoutubeProbeBudgetEstimatePercent / 100ULL; +} + +static uint64_t youtubeProbeBytesUsed(obs_output_t *output, uint64_t budgetStartBytes) +{ + const uint64_t currentBytes = output ? obs_output_get_total_bytes(output) : 0; + return currentBytes >= budgetStartBytes ? currentBytes - budgetStartBytes : 0; +} + +static bool youtubeSampleGroupFits(std::chrono::steady_clock::time_point deadline, uint64_t totalProbeBytes, int activeTarget, const std::vector &targets) +{ + long long requiredMs = kYoutubeProbeBudgetSlackMs; + uint64_t requiredBytes = 0; + int targetBeforeSample = activeTarget; + for (int target : targets) { + if (target <= 0) + return false; + int durationMs = kYoutubeProbeSampleMs; + if (target != targetBeforeSample) { + requiredMs += kYoutubeProbeSettleMs; + durationMs += kYoutubeProbeSettleMs; + } + requiredMs += kYoutubeProbeSampleMs; + requiredBytes += estimatedYoutubeSampleBytes(target, durationMs); + targetBeforeSample = target; + } + return std::chrono::steady_clock::now() + std::chrono::milliseconds(requiredMs) < deadline && totalProbeBytes + requiredBytes <= kYoutubeProbeMaxBytes; +} + +static bool runYoutubeProbeSample(const std::shared_ptr &session, ScratchResources &resources, const ProbeRequest &probe, int requestedTarget, + int &activeTarget, std::chrono::steady_clock::time_point youtubeDeadline, uint64_t budgetStartBytes, + uint64_t &totalProbeBytes, double progress, const char *eventCode, YoutubeProbeSample &sample, ProbeResult &result) +{ + totalProbeBytes = youtubeProbeBytesUsed(resources.output, budgetStartBytes); + if (totalProbeBytes >= kYoutubeProbeMaxBytes) { + result.errorCode = "youtube_probe_byte_budget_exhausted"; + return false; + } + + int target = requestedTarget; + const bool targetChanged = target != activeTarget; + if (targetChanged) { + obs_data_t *updatedSettings = obs_data_create(); + obs_data_set_int(updatedSettings, "bitrate", target); + obs_data_set_string(updatedSettings, "rate_control", "CBR"); + obs_data_set_string(updatedSettings, "preset", "veryfast"); + obs_data_set_int(updatedSettings, "keyint_sec", 2); + obs_service_apply_encoder_settings(resources.service, updatedSettings, nullptr); + target = (int)obs_data_get_int(updatedSettings, "bitrate"); + obs_encoder_update(resources.videoEncoder, updatedSettings); + obs_data_release(updatedSettings); + activeTarget = target; + } + + if (target <= 0) { + result.errorCode = "youtube_probe_invalid_applied_target"; + return false; + } + + pushEvent(session, "progress", "bandwidth", progress, eventCode, probe.legId, "active", probe.probeId, probe.provider, (uint32_t)target); + blog(LOG_INFO, + "[Auto Optimizer][YouTube Probe] Starting sample: purpose=%s, requested_video_target=%d Kbps, " + "applied_video_target=%d Kbps", + eventCode, requestedTarget, target); + + if (targetChanged) { + const auto settleDeadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kYoutubeProbeSettleMs); + if (settleDeadline > youtubeDeadline) { + result.errorCode = "youtube_probe_sample_deadline_exhausted"; + return false; + } + if (!waitForProbeInterval(session, resources.output, settleDeadline, result)) { + blog(LOG_WARNING, "[Auto Optimizer][YouTube Probe] Sample %d Kbps stopped during settle: %s", target, + result.errorCode.empty() ? (result.cancelled ? "cancelled" : "unknown") : result.errorCode.c_str()); + return false; + } + } + totalProbeBytes = youtubeProbeBytesUsed(resources.output, budgetStartBytes); + if (totalProbeBytes >= kYoutubeProbeMaxBytes) { + result.errorCode = "youtube_probe_byte_budget_exhausted"; + return false; + } + + const uint64_t startBytes = obs_output_get_total_bytes(resources.output); + const uint32_t startDropped = obs_output_get_frames_dropped(resources.output); + const uint32_t startFrames = obs_output_get_total_frames(resources.output); + const auto sampleStart = std::chrono::steady_clock::now(); + const auto sampleDeadline = sampleStart + std::chrono::milliseconds(kYoutubeProbeSampleMs); + if (sampleDeadline > youtubeDeadline) { + result.errorCode = "youtube_probe_sample_deadline_exhausted"; + return false; + } + auto subwindowStart = sampleStart; + auto nextSubwindow = sampleStart + std::chrono::milliseconds(kYoutubeProbeSubwindowMs); + uint64_t subwindowStartBytes = startBytes; + std::vector subwindowThroughputs; + std::vector congestionValues; + + while (std::chrono::steady_clock::now() < sampleDeadline) { + const float congestion = obs_output_get_congestion(resources.output); + congestionValues.push_back(congestion); + const auto tickDeadline = std::min(std::chrono::steady_clock::now() + std::chrono::milliseconds(50), sampleDeadline); + if (!waitForProbeInterval(session, resources.output, tickDeadline, result)) { + blog(LOG_WARNING, "[Auto Optimizer][YouTube Probe] Sample %d Kbps stopped during measurement: %s", target, + result.errorCode.empty() ? (result.cancelled ? "cancelled" : "unknown") : result.errorCode.c_str()); + return false; + } + totalProbeBytes = youtubeProbeBytesUsed(resources.output, budgetStartBytes); + if (totalProbeBytes > kYoutubeProbeMaxBytes) { + result.errorCode = "youtube_probe_byte_budget_exhausted"; + blog(LOG_WARNING, "[Auto Optimizer][YouTube Probe] Sample %d Kbps exceeded byte budget: used=%llu, budget=%llu", target, + (unsigned long long)totalProbeBytes, (unsigned long long)kYoutubeProbeMaxBytes); + return false; + } + + const auto now = std::chrono::steady_clock::now(); + if (now >= nextSubwindow || now >= sampleDeadline) { + const uint64_t subwindowEndBytes = obs_output_get_total_bytes(resources.output); + const uint64_t elapsedNs = (uint64_t)std::chrono::duration_cast(now - subwindowStart).count(); + if (elapsedNs > 0) { + const uint64_t bytes = subwindowEndBytes >= subwindowStartBytes ? subwindowEndBytes - subwindowStartBytes : 0; + subwindowThroughputs.push_back(bytes * 8ULL * 1000000000ULL / elapsedNs / 1000ULL); + } + subwindowStart = now; + subwindowStartBytes = subwindowEndBytes; + nextSubwindow += std::chrono::milliseconds(kYoutubeProbeSubwindowMs); + } + } + + const auto sampleEnd = std::chrono::steady_clock::now(); + const uint64_t endBytes = obs_output_get_total_bytes(resources.output); + const uint32_t endDropped = obs_output_get_frames_dropped(resources.output); + const uint32_t endFrames = obs_output_get_total_frames(resources.output); + const uint64_t elapsedNs = (uint64_t)std::chrono::duration_cast(sampleEnd - sampleStart).count(); + if (endBytes <= startBytes || elapsedNs == 0 || subwindowThroughputs.empty()) { + result.errorCode = "youtube_probe_no_data"; + blog(LOG_WARNING, + "[Auto Optimizer][YouTube Probe] Sample %d Kbps produced no measurable data: start_bytes=%llu, end_bytes=%llu, " + "elapsed_ns=%llu, subwindows=%llu", + target, (unsigned long long)startBytes, (unsigned long long)endBytes, (unsigned long long)elapsedNs, + (unsigned long long)subwindowThroughputs.size()); + return false; + } + + sample.targetVideoKbps = target; + sample.expectedAggregateKbps = (uint64_t)target + kYoutubeProbeAudioBitrateKbps; + sample.medianSubwindowAggregateKbps = medianValue(subwindowThroughputs); + sample.wholeWindowAggregateKbps = (endBytes - startBytes) * 8ULL * 1000000000ULL / elapsedNs / 1000ULL; + // The median filters isolated scheduler/network spikes, while the complete + // window catches multi-second stalls. Use the conservative value for both + // classification and recommendation. + sample.measuredAggregateKbps = std::min(sample.medianSubwindowAggregateKbps, sample.wholeWindowAggregateKbps); + sample.sampleBytes = endBytes - startBytes; + sample.frames = endFrames >= startFrames ? endFrames - startFrames : 0; + sample.droppedFrames = endDropped >= startDropped ? endDropped - startDropped : 0; + sample.elapsedMs = std::chrono::duration_cast(sampleEnd - sampleStart).count(); + sample.congestionSamples = (uint32_t)congestionValues.size(); + for (float congestion : congestionValues) { + sample.maximumCongestion = std::max(sample.maximumCongestion, congestion); + if (congestion >= kYoutubeProbeCongestionHigh) + sample.congestionHighSamples++; + if (congestion >= kYoutubeProbeCongestionSevere) + sample.congestionSevereSamples++; + } + std::sort(congestionValues.begin(), congestionValues.end()); + if (!congestionValues.empty()) { + const size_t percentileIndex = std::min(congestionValues.size() - 1, (congestionValues.size() * 95 + 99) / 100 - 1); + sample.p95Congestion = congestionValues[percentileIndex]; + } + sample.metrics = probePolicy::makeYoutubeProbeSampleMetrics((uint32_t)std::min(sample.measuredAggregateKbps, UINT32_MAX), + (uint32_t)std::min(sample.expectedAggregateKbps, UINT32_MAX), + sample.droppedFrames, sample.frames, sample.congestionHighSamples, + sample.congestionSevereSamples, sample.congestionSamples); + totalProbeBytes = youtubeProbeBytesUsed(resources.output, budgetStartBytes); + + const probePolicy::YoutubeProbeSampleClass sampleClass = probePolicy::classifyYoutubeProbeSample(sample.metrics); + blog(LOG_INFO, + "[Auto Optimizer][YouTube Probe] Sample result: purpose=%s, video_target=%d Kbps, expected_aggregate=%llu Kbps, " + "representative_aggregate=%llu Kbps, median_subwindow_aggregate=%llu Kbps, whole_window_aggregate=%llu Kbps, " + "elapsed=%lld ms, sample_bytes=%llu, frames=%u, " + "dropped=%u, max_congestion=%.3f, p95_congestion=%.3f, congestion_high=%u/%u, congestion_severe=%u/%u, " + "throughput_ratio=%.2f%%, drop_ratio=%.2f%%, congestion_high_ratio=%.2f%%, congestion_severe_ratio=%.2f%%, class=%s", + eventCode, target, (unsigned long long)sample.expectedAggregateKbps, (unsigned long long)sample.measuredAggregateKbps, + (unsigned long long)sample.medianSubwindowAggregateKbps, (unsigned long long)sample.wholeWindowAggregateKbps, sample.elapsedMs, + (unsigned long long)sample.sampleBytes, (unsigned int)sample.frames, (unsigned int)sample.droppedFrames, (double)sample.maximumCongestion, + (double)sample.p95Congestion, (unsigned int)sample.congestionHighSamples, (unsigned int)sample.congestionSamples, + (unsigned int)sample.congestionSevereSamples, (unsigned int)sample.congestionSamples, (double)sample.metrics.throughputBasisPoints / 100.0, + (double)sample.metrics.dropBasisPoints / 100.0, (double)sample.metrics.congestionHighBasisPoints / 100.0, + (double)sample.metrics.congestionSevereBasisPoints / 100.0, youtubeSampleClassName(sampleClass)); + return true; +} + static ProbeResult runRtmpProbe(const std::shared_ptr &session, ProbeRequest &probe, const LegRequest &leg, double slotStartProgress, double slotEndProgress) { ProbeResult result; result.provider = probe.provider; result.legId = probe.legId; - result.method = probe.provider == "youtube" ? "youtube-unbound-ramp-v1" : "twitch-bandwidth-test-v1"; + result.method = probe.provider == "youtube" ? "youtube-unbound-ramp" : "twitch-bandwidth-test"; result.headroomPercent = 100 - (probe.provider == "youtube" ? kYoutubeProbeSafeMultiplierPercent : kTwitchProbeSafeMultiplierPercent); ScratchResources resources(*session); @@ -1642,6 +1943,7 @@ static ProbeResult runRtmpProbe(const std::shared_ptr &session, ProbeRe result.errorCode = result.provider + "_probe_connect_failed"; return result; } + const uint64_t probeBudgetStartBytes = obs_output_get_total_bytes(resources.output); if (probe.provider == "youtube") { blog(LOG_INFO, "[Auto Optimizer][YouTube Probe] RTMP output is active; waiting for ingest confirmation"); @@ -1683,7 +1985,7 @@ static ProbeResult runRtmpProbe(const std::shared_ptr &session, ProbeRe result.success = result.measuredKbps > 0; } else { const int ladder[] = {1000, 2000, 4000, 6000, 8000, 10000, 12000}; - uint64_t totalProbeBytes = 0; + uint64_t totalProbeBytes = youtubeProbeBytesUsed(resources.output, probeBudgetStartBytes); probePolicy::YoutubeRampEvidence rampEvidence; const int effectiveCeilingKbps = probePolicy::effectiveProbeCeilingKbps(kYoutubeProbeMaximumBitrateKbps, result.platformCapKbps, leg.limits.maxBitrateKbps); @@ -1696,149 +1998,257 @@ static ProbeResult runRtmpProbe(const std::shared_ptr &session, ProbeRe lastPlannedTarget = plannedTarget; } } - int lastTarget = 0; - size_t emittedRungCount = 0; std::string terminationReason = "ladder_exhausted"; + int activeTarget = initialBitrate; + size_t confirmationEpisodes = 0; + bool measurementConclusive = false; const auto rampStarted = std::chrono::steady_clock::now(); const auto probeElapsedMs = std::chrono::duration_cast(rampStarted - probeStarted).count(); const auto remainingMs = std::chrono::duration_cast(youtubeDeadline - rampStarted).count(); blog(LOG_INFO, - "[Auto Optimizer][YouTube Probe] Ladder configuration: effective_video_ceiling=%d Kbps, settle=%d ms, sample=%d ms, " - "total_timeout=%d ms, probe_elapsed=%lld ms, remaining=%lld ms, byte_budget=%llu", - effectiveCeilingKbps, kYoutubeProbeSettleMs, kYoutubeProbeSampleMs, kYoutubeProbeTotalTimeoutMs, (long long)probeElapsedMs, - (long long)remainingMs, (unsigned long long)kYoutubeProbeMaxBytes); - for (const auto &[ladderTarget, plannedTarget] : plannedTargets) { - const auto rungCheckTime = std::chrono::steady_clock::now(); - if (rungCheckTime + std::chrono::milliseconds(kYoutubeProbeSettleMs + kYoutubeProbeSampleMs) >= youtubeDeadline) { - terminationReason = "total_deadline_before_next_rung"; - const auto rungRemainingMs = std::chrono::duration_cast(youtubeDeadline - rungCheckTime).count(); - blog(LOG_INFO, "[Auto Optimizer][YouTube Probe] Skipping ladder target %d Kbps: remaining=%lld ms, required=%d ms", - ladderTarget, (long long)rungRemainingMs, kYoutubeProbeSettleMs + kYoutubeProbeSampleMs); - break; - } - int target = plannedTarget; - if (target <= 0 || target <= lastTarget) - continue; - - obs_data_t *updatedSettings = obs_data_create(); - obs_data_set_int(updatedSettings, "bitrate", target); - obs_data_set_string(updatedSettings, "rate_control", "CBR"); - obs_data_set_string(updatedSettings, "preset", "veryfast"); - obs_data_set_int(updatedSettings, "keyint_sec", 2); - obs_service_apply_encoder_settings(resources.service, updatedSettings, nullptr); - target = (int)obs_data_get_int(updatedSettings, "bitrate"); - obs_encoder_update(resources.videoEncoder, updatedSettings); - obs_data_release(updatedSettings); - if (target <= lastTarget) - continue; - lastTarget = target; - const double rungProgress = - probePolicy::probeSubstepProgress(slotStartProgress, slotEndProgress, emittedRungCount, plannedTargets.size()); - emittedRungCount++; - pushEvent(session, "progress", "bandwidth", rungProgress, "youtube_probe_measuring", probe.legId, "active", probe.probeId, - probe.provider, (uint32_t)target); - blog(LOG_INFO, "[Auto Optimizer][YouTube Probe] Starting rung: ladder_target=%d Kbps, applied_video_target=%d Kbps", ladderTarget, - target); - - if (!waitForProbeInterval(session, resources.output, - std::chrono::steady_clock::now() + std::chrono::milliseconds(kYoutubeProbeSettleMs), result)) { - blog(LOG_WARNING, "[Auto Optimizer][YouTube Probe] Rung %d Kbps stopped during settle: %s", target, - result.errorCode.empty() ? (result.cancelled ? "cancelled" : "unknown") : result.errorCode.c_str()); - return result; - } - const uint64_t startBytes = obs_output_get_total_bytes(resources.output); - const uint32_t startDropped = obs_output_get_frames_dropped(resources.output); - const uint32_t startFrames = obs_output_get_total_frames(resources.output); - float maximumCongestion = 0.0f; - const auto sampleStart = std::chrono::steady_clock::now(); - const auto sampleDeadline = std::min(sampleStart + std::chrono::milliseconds(kYoutubeProbeSampleMs), youtubeDeadline); - while (std::chrono::steady_clock::now() < sampleDeadline) { - maximumCongestion = std::max(maximumCongestion, obs_output_get_congestion(resources.output)); - if (!waitForProbeInterval(session, resources.output, std::chrono::steady_clock::now() + std::chrono::milliseconds(50), - result)) { - blog(LOG_WARNING, "[Auto Optimizer][YouTube Probe] Rung %d Kbps stopped during measurement: %s", target, - result.errorCode.empty() ? (result.cancelled ? "cancelled" : "unknown") : result.errorCode.c_str()); + "[Auto Optimizer][YouTube Probe] Adaptive ladder configuration: effective_video_ceiling=%d Kbps, settle=%d ms, " + "sample=%d ms, subwindow=%d ms, total_timeout=%d ms, probe_elapsed=%lld ms, remaining=%lld ms, byte_budget=%llu, " + "maximum_confirmation_episodes=%d", + effectiveCeilingKbps, kYoutubeProbeSettleMs, kYoutubeProbeSampleMs, kYoutubeProbeSubwindowMs, kYoutubeProbeTotalTimeoutMs, + (long long)probeElapsedMs, (long long)remainingMs, (unsigned long long)kYoutubeProbeMaxBytes, kYoutubeProbeMaximumConfirmationEpisodes); + + if (plannedTargets.empty()) { + terminationReason = "no_planned_targets"; + result.errorCode = "youtube_probe_no_passing_step"; + result.observedThroughputReliable = false; + } else { + const int baselineTarget = plannedTargets.front().second; + if (!youtubeSampleGroupFits(youtubeDeadline, totalProbeBytes, activeTarget, {baselineTarget, baselineTarget})) { + terminationReason = "budget_before_baseline"; + result.errorCode = "youtube_probe_baseline_budget_exhausted"; + result.observedThroughputReliable = false; + } else { + YoutubeProbeSample baselineFirst; + YoutubeProbeSample baselineSecond; + if (!runYoutubeProbeSample(session, resources, probe, baselineTarget, activeTarget, youtubeDeadline, probeBudgetStartBytes, + totalProbeBytes, + youtubeProbeStepProgress(slotStartProgress, slotEndProgress, 0, plannedTargets.size(), 0.15), + "youtube_probe_baseline", baselineFirst, result)) + return result; + if (!runYoutubeProbeSample(session, resources, probe, baselineTarget, activeTarget, youtubeDeadline, probeBudgetStartBytes, + totalProbeBytes, + youtubeProbeStepProgress(slotStartProgress, slotEndProgress, 0, plannedTargets.size(), 0.40), + "youtube_probe_baseline", baselineSecond, result)) return result; + + probePolicy::YoutubeBaselineAssessment baseline = + probePolicy::assessYoutubeBaseline(baselineFirst.metrics, baselineSecond.metrics); + std::vector baselineThroughputs{baselineFirst.measuredAggregateKbps, baselineSecond.measuredAggregateKbps}; + bool usedThirdBaselineSample = false; + if (baseline.decision == probePolicy::YoutubeBaselineDecision::NeedsThird) { + totalProbeBytes = youtubeProbeBytesUsed(resources.output, probeBudgetStartBytes); + if (!youtubeSampleGroupFits(youtubeDeadline, totalProbeBytes, activeTarget, {baselineTarget})) { + terminationReason = "budget_before_third_baseline_sample"; + result.errorCode = "youtube_probe_baseline_confirmation_budget_exhausted"; + result.observedThroughputReliable = false; + } else { + YoutubeProbeSample baselineThird; + if (!runYoutubeProbeSample(session, resources, probe, baselineTarget, activeTarget, youtubeDeadline, + probeBudgetStartBytes, totalProbeBytes, + youtubeProbeStepProgress(slotStartProgress, slotEndProgress, 0, + plannedTargets.size(), 0.65), + "youtube_probe_baseline", baselineThird, result)) + return result; + baselineThroughputs.push_back(baselineThird.measuredAggregateKbps); + baseline = probePolicy::resolveYoutubeBaseline(baselineFirst.metrics, baselineSecond.metrics, + baselineThird.metrics); + usedThirdBaselineSample = true; + } + } + + if (result.errorCode.empty()) { + const uint64_t baselineBasis = baselineThroughputs.size() == 2 + ? std::min(baselineThroughputs[0], baselineThroughputs[1]) + : medianValue(baselineThroughputs); + result.measuredKbps = baselineBasis; + blog(baseline.decision == probePolicy::YoutubeBaselineDecision::Unstable ? LOG_WARNING : LOG_INFO, + "[Auto Optimizer][YouTube Probe] Baseline decision: decision=%s, samples=%llu, " + "recommendation_basis=%llu Kbps, throughput_reference=%.2f%%, drop_reference=%.2f%%, " + "congestion_high_reference=%.2f%%, congestion_severe_reference=%.2f%%", + youtubeBaselineDecisionName(baseline.decision), (unsigned long long)baselineThroughputs.size(), + (unsigned long long)baselineBasis, (double)baseline.reference.throughputBasisPoints / 100.0, + (double)baseline.reference.dropBasisPoints / 100.0, (double)baseline.reference.congestionHighBasisPoints / 100.0, + (double)baseline.reference.congestionSevereBasisPoints / 100.0); + + if (baseline.decision == probePolicy::YoutubeBaselineDecision::Unstable || + baseline.decision == probePolicy::YoutubeBaselineDecision::NeedsThird) { + terminationReason = "unstable_baseline"; + result.stability = ProbeStability::Unstable; + result.observedThroughputReliable = false; + result.errorCode = "youtube_probe_unstable_connection"; + } else { + if (baseline.decision == probePolicy::YoutubeBaselineDecision::Impaired) + result.stability = ProbeStability::Degraded; + else if (usedThirdBaselineSample) + result.stability = ProbeStability::Variable; + + rampEvidence.observe(baselineBasis, true); + YoutubeProbeSample lastAccepted = baselineFirst; + lastAccepted.targetVideoKbps = baselineTarget; + lastAccepted.expectedAggregateKbps = (uint64_t)baselineTarget + kYoutubeProbeAudioBitrateKbps; + lastAccepted.measuredAggregateKbps = baselineBasis; + lastAccepted.metrics = baseline.reference; + result.ceilingReached = probePolicy::reachedEffectiveProbeCeiling(baselineTarget, effectiveCeilingKbps); + if (result.ceilingReached) { + terminationReason = "effective_ceiling_reached_at_baseline"; + measurementConclusive = true; + } + + for (size_t targetIndex = 1; targetIndex < plannedTargets.size() && !result.ceilingReached; targetIndex++) { + const auto &[ladderTarget, plannedTarget] = plannedTargets[targetIndex]; + totalProbeBytes = youtubeProbeBytesUsed(resources.output, probeBudgetStartBytes); + if (!youtubeSampleGroupFits(youtubeDeadline, totalProbeBytes, activeTarget, {plannedTarget})) { + terminationReason = "budget_before_next_rung"; + break; + } + + YoutubeProbeSample highFirst; + if (!runYoutubeProbeSample(session, resources, probe, plannedTarget, activeTarget, youtubeDeadline, + probeBudgetStartBytes, totalProbeBytes, + youtubeProbeStepProgress(slotStartProgress, slotEndProgress, targetIndex, + plannedTargets.size(), 0.20), + "youtube_probe_measuring", highFirst, result)) + return result; + + if (probePolicy::youtubeSampleAccepted(highFirst.metrics, baseline)) { + rampEvidence.observe(highFirst.measuredAggregateKbps, true); + result.measuredKbps = rampEvidence.recommendationBasisKbps; + lastAccepted = highFirst; + result.ceilingReached = probePolicy::reachedEffectiveProbeCeiling(highFirst.targetVideoKbps, + effectiveCeilingKbps); + if (result.ceilingReached) { + terminationReason = "effective_ceiling_reached"; + measurementConclusive = true; + } + if (highFirst.targetVideoKbps < ladderTarget) { + terminationReason = "provider_or_request_cap_reached"; + measurementConclusive = true; + break; + } + continue; + } + + confirmationEpisodes++; + if (confirmationEpisodes > kYoutubeProbeMaximumConfirmationEpisodes) { + terminationReason = "confirmation_episode_limit_exceeded"; + result.stability = ProbeStability::Unstable; + result.observedThroughputReliable = false; + result.errorCode = "youtube_probe_unstable_connection"; + break; + } + totalProbeBytes = youtubeProbeBytesUsed(resources.output, probeBudgetStartBytes); + if (!youtubeSampleGroupFits(youtubeDeadline, totalProbeBytes, activeTarget, + {lastAccepted.targetVideoKbps, plannedTarget})) { + terminationReason = "confirmation_budget_exhausted_after_unconfirmed_failure"; + if (result.stability == ProbeStability::Stable) + result.stability = ProbeStability::Variable; + break; + } + + YoutubeProbeSample lowControl; + YoutubeProbeSample highRetry; + if (!runYoutubeProbeSample(session, resources, probe, lastAccepted.targetVideoKbps, activeTarget, + youtubeDeadline, probeBudgetStartBytes, totalProbeBytes, + youtubeProbeStepProgress(slotStartProgress, slotEndProgress, targetIndex, + plannedTargets.size(), 0.45), + "youtube_probe_confirming_stability", lowControl, result)) + return result; + if (!runYoutubeProbeSample(session, resources, probe, plannedTarget, activeTarget, youtubeDeadline, + probeBudgetStartBytes, totalProbeBytes, + youtubeProbeStepProgress(slotStartProgress, slotEndProgress, targetIndex, + plannedTargets.size(), 0.70), + "youtube_probe_retrying", highRetry, result)) + return result; + + const bool lowRecovered = + probePolicy::youtubeLowControlRecovered(lowControl.metrics, lastAccepted.metrics, baseline); + const bool highAccepted = probePolicy::youtubeSampleAccepted(highRetry.metrics, baseline); + const probePolicy::YoutubeConfirmationDecision confirmation = + probePolicy::decideYoutubeConfirmation(lowRecovered, highAccepted); + blog(LOG_INFO, + "[Auto Optimizer][YouTube Probe] Confirmation decision: episode=%llu, high_target=%d Kbps, " + "low_recovered=%s, high_retry_accepted=%s, decision=%s", + (unsigned long long)confirmationEpisodes, plannedTarget, lowRecovered ? "true" : "false", + highAccepted ? "true" : "false", youtubeConfirmationDecisionName(confirmation)); + + if (confirmation == probePolicy::YoutubeConfirmationDecision::CapacityKnee) { + const uint64_t confirmedBasis = + std::min(lastAccepted.measuredAggregateKbps, lowControl.measuredAggregateKbps); + rampEvidence.observe(confirmedBasis, true); + result.measuredKbps = rampEvidence.recommendationBasisKbps; + terminationReason = "confirmed_capacity_knee"; + measurementConclusive = true; + break; + } + if (confirmation == probePolicy::YoutubeConfirmationDecision::TransientRecovered) { + if (result.stability == ProbeStability::Stable) + result.stability = ProbeStability::Variable; + rampEvidence.observe(highRetry.measuredAggregateKbps, true); + result.measuredKbps = rampEvidence.recommendationBasisKbps; + lastAccepted = highRetry; + result.ceilingReached = probePolicy::reachedEffectiveProbeCeiling(highRetry.targetVideoKbps, + effectiveCeilingKbps); + if (result.ceilingReached) { + terminationReason = "effective_ceiling_reached_after_retry"; + measurementConclusive = true; + } + if (highRetry.targetVideoKbps < ladderTarget) { + terminationReason = "provider_or_request_cap_reached_after_retry"; + measurementConclusive = true; + break; + } + continue; + } + + terminationReason = confirmation == probePolicy::YoutubeConfirmationDecision::PathUnstable + ? "path_unstable" + : "inconsistent_confirmation"; + result.stability = ProbeStability::Unstable; + result.observedThroughputReliable = false; + result.errorCode = "youtube_probe_unstable_connection"; + break; + } + } } - } - const auto sampleEnd = std::chrono::steady_clock::now(); - const uint64_t endBytes = obs_output_get_total_bytes(resources.output); - const uint32_t endDropped = obs_output_get_frames_dropped(resources.output); - const uint32_t endFrames = obs_output_get_total_frames(resources.output); - const uint64_t elapsedNs = (uint64_t)std::chrono::duration_cast(sampleEnd - sampleStart).count(); - if (endBytes <= startBytes || elapsedNs == 0) { - terminationReason = "no_sample_data"; - blog(LOG_WARNING, - "[Auto Optimizer][YouTube Probe] Rung %d Kbps produced no measurable data: start_bytes=%llu, end_bytes=%llu, elapsed_ns=%llu", - target, (unsigned long long)startBytes, (unsigned long long)endBytes, (unsigned long long)elapsedNs); - break; - } - const uint64_t measured = ((endBytes - startBytes) * 8ULL * 1000000000ULL) / elapsedNs / 1000ULL; - totalProbeBytes += endBytes - startBytes; - const uint32_t frameDelta = endFrames >= startFrames ? endFrames - startFrames : 0; - const uint32_t droppedDelta = endDropped >= startDropped ? endDropped - startDropped : 0; - const uint64_t expectedAggregateKbps = (uint64_t)target + kYoutubeProbeAudioBitrateKbps; - const bool throughputPassed = measured * 100ULL >= expectedAggregateKbps * 90ULL; - const bool dropsPassed = frameDelta == 0 || (uint64_t)droppedDelta * 100ULL <= (uint64_t)frameDelta * 2ULL; - const bool congestionPassed = maximumCongestion < 0.20f; - const bool rungPassed = throughputPassed && dropsPassed && congestionPassed; - const auto elapsedMs = std::chrono::duration_cast(sampleEnd - sampleStart).count(); - const uint64_t sampleBytes = endBytes - startBytes; - blog(LOG_INFO, - "[Auto Optimizer][YouTube Probe] Rung result: video_target=%d Kbps, expected_aggregate=%llu Kbps, " - "measured_aggregate=%llu Kbps, elapsed=%lld ms, sample_bytes=%llu, frames=%u, dropped=%u, max_congestion=%.3f, " - "throughput_passed=%s, drops_passed=%s, congestion_passed=%s, passed=%s", - target, (unsigned long long)expectedAggregateKbps, (unsigned long long)measured, (long long)elapsedMs, - (unsigned long long)sampleBytes, (unsigned int)frameDelta, (unsigned int)droppedDelta, (double)maximumCongestion, - throughputPassed ? "true" : "false", dropsPassed ? "true" : "false", congestionPassed ? "true" : "false", - rungPassed ? "true" : "false"); - if (!throughputPassed || !dropsPassed || !congestionPassed) { - terminationReason = "quality_gate_failed"; - // Even a failed first rung is useful evidence: it is a - // conservative upper bound and must not be discarded in - // favor of a higher pre-probe bitrate. - rampEvidence.observe(measured, false, expectedAggregateKbps); - result.measuredKbps = rampEvidence.recommendationBasisKbps; - break; - } - rampEvidence.observe(measured, true); - result.measuredKbps = rampEvidence.recommendationBasisKbps; - result.ceilingReached = probePolicy::reachedEffectiveProbeCeiling(target, effectiveCeilingKbps); - if (result.ceilingReached) - terminationReason = "effective_ceiling_reached"; - if (target < ladderTarget) { - terminationReason = "provider_or_request_cap_reached"; - break; - } - if (totalProbeBytes >= kYoutubeProbeMaxBytes) { - terminationReason = "byte_budget_reached"; - break; } } - if (result.measuredKbps == 0) { - blog(LOG_WARNING, "[Auto Optimizer][YouTube Probe] Ladder failed without usable throughput: termination=%s", terminationReason.c_str()); - result.errorCode = "youtube_probe_no_passing_step"; - return result; + + if (result.stability != ProbeStability::Unstable && rampEvidence.passedStep && !measurementConclusive) { + result.observedThroughputReliable = false; + if (result.errorCode.empty()) + result.errorCode = "youtube_probe_inconclusive"; } - const uint64_t uncappedSafeKbps = rampEvidence.safeVideoKbps(kYoutubeProbeSafeMultiplierPercent, kYoutubeProbeAudioBitrateKbps); - result.safeKbps = uncappedSafeKbps; - if (result.platformCapKbps > 0) - result.safeKbps = std::min(result.safeKbps, (uint64_t)result.platformCapKbps); - if (leg.limits.maxBitrateKbps > 0) - result.safeKbps = std::min(result.safeKbps, (uint64_t)leg.limits.maxBitrateKbps); - blog(rampEvidence.passedStep ? LOG_INFO : LOG_WARNING, - "[Auto Optimizer][YouTube Probe] Ladder summary: passed_step=%s, recommendation_basis=%llu Kbps, failed_upper_bound=%llu Kbps, " - "safe_multiplier=%d%%, audio_reserve=%d Kbps, uncapped_safe_video=%llu Kbps, final_safe_video=%llu Kbps, " - "platform_cap=%d Kbps, request_cap=%d Kbps, total_sample_bytes=%llu, ceiling_reached=%s, termination=%s", - rampEvidence.passedStep ? "true" : "false", (unsigned long long)rampEvidence.recommendationBasisKbps, - (unsigned long long)rampEvidence.failedUpperBoundKbps, kYoutubeProbeSafeMultiplierPercent, kYoutubeProbeAudioBitrateKbps, - (unsigned long long)uncappedSafeKbps, (unsigned long long)result.safeKbps, result.platformCapKbps, leg.limits.maxBitrateKbps, - (unsigned long long)totalProbeBytes, result.ceilingReached ? "true" : "false", terminationReason.c_str()); - if (!rampEvidence.passedStep) { - result.errorCode = "youtube_probe_no_passing_step"; - return result; + + uint64_t uncappedSafeKbps = 0; + if (result.stability != ProbeStability::Unstable && rampEvidence.passedStep && measurementConclusive) { + uncappedSafeKbps = rampEvidence.safeVideoKbps(kYoutubeProbeSafeMultiplierPercent, kYoutubeProbeAudioBitrateKbps); + result.safeKbps = uncappedSafeKbps; + if (result.platformCapKbps > 0) + result.safeKbps = std::min(result.safeKbps, (uint64_t)result.platformCapKbps); + if (leg.limits.maxBitrateKbps > 0) + result.safeKbps = std::min(result.safeKbps, (uint64_t)leg.limits.maxBitrateKbps); + result.success = result.safeKbps > 0; + } else { + result.safeKbps = 0; + result.success = false; + if (result.errorCode.empty()) + result.errorCode = "youtube_probe_no_passing_step"; } - result.success = true; + blog(result.success ? LOG_INFO : LOG_WARNING, + "[Auto Optimizer][YouTube Probe] Adaptive ladder summary: passed_step=%s, stability=%s, " + "observed_throughput_reliable=%s, recommendation_basis=%llu Kbps, safe_multiplier=%d%%, audio_reserve=%d Kbps, " + "uncapped_safe_video=%llu Kbps, final_safe_video=%llu Kbps, platform_cap=%d Kbps, request_cap=%d Kbps, " + "total_output_bytes=%llu, ceiling_reached=%s, conclusive=%s, confirmation_episodes=%llu, termination=%s, error=%s", + rampEvidence.passedStep ? "true" : "false", probeStabilityName(result.stability), result.observedThroughputReliable ? "true" : "false", + (unsigned long long)rampEvidence.recommendationBasisKbps, kYoutubeProbeSafeMultiplierPercent, kYoutubeProbeAudioBitrateKbps, + (unsigned long long)uncappedSafeKbps, (unsigned long long)result.safeKbps, result.platformCapKbps, leg.limits.maxBitrateKbps, + (unsigned long long)totalProbeBytes, result.ceilingReached ? "true" : "false", measurementConclusive ? "true" : "false", + (unsigned long long)confirmationEpisodes, terminationReason.c_str(), result.errorCode.empty() ? "none" : result.errorCode.c_str()); } obs_output_stop(resources.output); @@ -1934,9 +2344,10 @@ static void runSession(const std::shared_ptr &session) if (result.provider == "youtube") { blog(result.success ? LOG_INFO : LOG_WARNING, "[Auto Optimizer][YouTube Probe] Probe summary: success=%s, cancelled=%s, measured_aggregate=%llu Kbps, " - "safe_video=%llu Kbps, ceiling_reached=%s, elapsed=%lld ms, error=%s", + "safe_video=%llu Kbps, ceiling_reached=%s, stability=%s, observed_throughput_reliable=%s, elapsed=%lld ms, error=%s", result.success ? "true" : "false", result.cancelled ? "true" : "false", (unsigned long long)result.measuredKbps, - (unsigned long long)result.safeKbps, result.ceilingReached ? "true" : "false", (long long)probeRunElapsedMs, + (unsigned long long)result.safeKbps, result.ceilingReached ? "true" : "false", probeStabilityName(result.stability), + result.observedThroughputReliable ? "true" : "false", (long long)probeRunElapsedMs, result.errorCode.empty() ? "none" : result.errorCode.c_str()); } if (probe.provider == "youtube") { @@ -1950,9 +2361,11 @@ static void runSession(const std::shared_ptr &session) completeCancelled(session); return; } - pushEvent(session, "progress", "bandwidth", endProgress, - result.success ? result.provider + "_probe_completed" : result.provider + "_probe_failed_estimate_used", result.legId, - result.success ? "active" : "estimated", probe.probeId, probe.provider); + const std::string completionCode = result.success ? result.provider + "_probe_completed" + : result.stability == ProbeStability::Unstable ? result.provider + "_probe_unstable_estimate_used" + : result.provider + "_probe_failed_estimate_used"; + pushEvent(session, "progress", "bandwidth", endProgress, completionCode, result.legId, result.success ? "active" : "estimated", probe.probeId, + probe.provider); probeResults.push_back(std::move(result)); } clearProbeSecrets(*session); @@ -2013,6 +2426,17 @@ static void runSession(const std::shared_ptr &session) // Never turn a low measurement into a higher recommendation merely to // satisfy a nominal bitrate floor. Surface the low-confidence result and // let Desktop decide how to explain an insufficient connection. + const bool hasDegradedProbe = std::any_of(legProbeResults.begin(), legProbeResults.end(), + [](const ProbeResult *result) { return result->stability == ProbeStability::Degraded; }); + const bool hasVariableProbe = std::any_of(legProbeResults.begin(), legProbeResults.end(), + [](const ProbeResult *result) { return result->stability == ProbeStability::Variable; }); + if (hasDegradedProbe && recommendation.confidence != "low") { + recommendation.confidence = "low"; + recommendation.reason = "unstable_connection"; + } else if (hasVariableProbe && recommendation.confidence == "high") { + recommendation.confidence = "medium"; + recommendation.reason = "connection_variability_detected"; + } if (safeKbps < 500) { recommendation.confidence = "low"; recommendation.reason = "insufficient_bandwidth"; @@ -2020,14 +2444,18 @@ static void runSession(const std::shared_ptr &session) recommendation.value.bitrateKbps = (int)std::clamp(safeKbps, 1, kYoutubeProbeMaximumBitrateKbps); } else if (requiredProbeCount > 0) { recommendation.confidence = "low"; - recommendation.reason = session->topology == "cloud-multistream" ? "indirect_provider_probe_failed" : "probe_failed"; - // A failed YouTube rung can still report observed throughput. - // Treat its safe value as an upper bound on the estimate so a - // failed low rung can never fall back to a higher current bitrate. + const bool hasUnstableProbe = std::any_of(legProbeResults.begin(), legProbeResults.end(), + [](const ProbeResult *result) { return result->stability == ProbeStability::Unstable; }); + recommendation.reason = hasUnstableProbe ? "unstable_connection" + : session->topology == "cloud-multistream" ? "indirect_provider_probe_failed" + : "probe_failed"; + // A failed probe can still have trustworthy throughput evidence. + // Unstable observations are deliberately excluded: they describe a + // variable path, not a defensible bandwidth ceiling. uint64_t observedSafeKbps = UINT64_MAX; bool hasObservedThroughput = false; for (const ProbeResult *result : legProbeResults) { - if (result->measuredKbps > 0) { + if (result->observedThroughputReliable && result->measuredKbps > 0 && result->safeKbps > 0) { hasObservedThroughput = true; observedSafeKbps = std::min(observedSafeKbps, result->safeKbps); } @@ -2119,7 +2547,7 @@ void CreateSession(void *, const int64_t, const std::vector &args, s } auto session = std::make_shared(); - session->id = "autoconfig-v2-" + std::to_string(os_gettime_ns()) + "-" + std::to_string(nextSessionId.fetch_add(1)); + session->id = "autoconfig-" + std::to_string(os_gettime_ns()) + "-" + std::to_string(nextSessionId.fetch_add(1)); std::string error; if (!parseRequest(args[0].value_str, *session, error)) { returnError(rval, error.c_str()); diff --git a/source/autoconfig-probe-policy.hpp b/source/autoconfig-probe-policy.hpp index ab530319e..d8ae3960d 100644 --- a/source/autoconfig-probe-policy.hpp +++ b/source/autoconfig-probe-policy.hpp @@ -7,6 +7,163 @@ namespace autoConfig { namespace probePolicy { +constexpr uint32_t kBasisPointsScale = 10000; +constexpr uint32_t kYoutubeCleanThroughputMinimumBasisPoints = 9000; +constexpr uint32_t kYoutubeCleanDropMaximumBasisPoints = 200; +constexpr uint32_t kYoutubeCleanCongestionHighMaximumBasisPoints = 1000; +constexpr uint32_t kYoutubeCleanCongestionSevereMaximumBasisPoints = 200; +constexpr uint32_t kYoutubeHardThroughputMinimumBasisPoints = 7500; +constexpr uint32_t kYoutubeHardDropMaximumBasisPoints = 500; +constexpr uint32_t kYoutubeHardCongestionHighMaximumBasisPoints = 3000; +constexpr uint32_t kYoutubeHardCongestionSevereMaximumBasisPoints = 1000; + +struct YoutubeProbeSampleMetrics { + uint32_t throughputBasisPoints = 0; + uint32_t dropBasisPoints = 0; + uint32_t congestionHighBasisPoints = 0; + uint32_t congestionSevereBasisPoints = 0; +}; + +enum class YoutubeProbeSampleClass { Clean, Marginal, Hard }; + +enum class YoutubeBaselineDecision { Clean, Impaired, NeedsThird, Unstable }; + +struct YoutubeBaselineAssessment { + YoutubeBaselineDecision decision = YoutubeBaselineDecision::NeedsThird; + YoutubeProbeSampleMetrics reference; +}; + +enum class YoutubeConfirmationDecision { CapacityKnee, TransientRecovered, PathUnstable, Inconsistent }; + +inline uint32_t ratioBasisPoints(uint32_t numerator, uint32_t denominator) +{ + if (denominator == 0) + return 0; + + const uint64_t scaled = (uint64_t)numerator * kBasisPointsScale / denominator; + return (uint32_t)std::min(scaled, UINT32_MAX); +} + +inline YoutubeProbeSampleMetrics makeYoutubeProbeSampleMetrics(uint32_t measuredAggregateKbps, uint32_t expectedAggregateKbps, uint32_t droppedFrames, + uint32_t totalFrames, uint32_t congestionHighSamples, uint32_t congestionSevereSamples, + uint32_t congestionSamples) +{ + return {ratioBasisPoints(measuredAggregateKbps, expectedAggregateKbps), ratioBasisPoints(droppedFrames, totalFrames), + ratioBasisPoints(congestionHighSamples, congestionSamples), ratioBasisPoints(congestionSevereSamples, congestionSamples)}; +} + +inline YoutubeProbeSampleClass classifyYoutubeProbeSample(const YoutubeProbeSampleMetrics &sample) +{ + if (sample.throughputBasisPoints < kYoutubeHardThroughputMinimumBasisPoints || sample.dropBasisPoints > kYoutubeHardDropMaximumBasisPoints || + sample.congestionHighBasisPoints > kYoutubeHardCongestionHighMaximumBasisPoints || + sample.congestionSevereBasisPoints > kYoutubeHardCongestionSevereMaximumBasisPoints) + return YoutubeProbeSampleClass::Hard; + + if (sample.throughputBasisPoints >= kYoutubeCleanThroughputMinimumBasisPoints && sample.dropBasisPoints <= kYoutubeCleanDropMaximumBasisPoints && + sample.congestionHighBasisPoints <= kYoutubeCleanCongestionHighMaximumBasisPoints && + sample.congestionSevereBasisPoints <= kYoutubeCleanCongestionSevereMaximumBasisPoints) + return YoutubeProbeSampleClass::Clean; + + return YoutubeProbeSampleClass::Marginal; +} + +inline uint32_t absoluteDifference(uint32_t left, uint32_t right) +{ + return left > right ? left - right : right - left; +} + +inline uint32_t medianOfThree(uint32_t first, uint32_t second, uint32_t third) +{ + return (uint32_t)(((uint64_t)first + second + third) - std::min({first, second, third}) - std::max({first, second, third})); +} + +inline YoutubeProbeSampleMetrics conservativeReference(const YoutubeProbeSampleMetrics &first, const YoutubeProbeSampleMetrics &second) +{ + return {std::min(first.throughputBasisPoints, second.throughputBasisPoints), std::max(first.dropBasisPoints, second.dropBasisPoints), + std::max(first.congestionHighBasisPoints, second.congestionHighBasisPoints), + std::max(first.congestionSevereBasisPoints, second.congestionSevereBasisPoints)}; +} + +inline bool youtubeBaselineSamplesSimilar(const YoutubeProbeSampleMetrics &first, const YoutubeProbeSampleMetrics &second) +{ + return absoluteDifference(first.throughputBasisPoints, second.throughputBasisPoints) <= 500 && + absoluteDifference(first.dropBasisPoints, second.dropBasisPoints) <= 100 && + absoluteDifference(first.congestionHighBasisPoints, second.congestionHighBasisPoints) <= 1000 && + absoluteDifference(first.congestionSevereBasisPoints, second.congestionSevereBasisPoints) <= 500; +} + +inline YoutubeBaselineAssessment assessYoutubeBaseline(const YoutubeProbeSampleMetrics &first, const YoutubeProbeSampleMetrics &second) +{ + const YoutubeProbeSampleClass firstClass = classifyYoutubeProbeSample(first); + const YoutubeProbeSampleClass secondClass = classifyYoutubeProbeSample(second); + const YoutubeProbeSampleMetrics reference = conservativeReference(first, second); + + if (firstClass == YoutubeProbeSampleClass::Hard && secondClass == YoutubeProbeSampleClass::Hard) + return {YoutubeBaselineDecision::Unstable, reference}; + if (firstClass == YoutubeProbeSampleClass::Clean && secondClass == YoutubeProbeSampleClass::Clean) + return {youtubeBaselineSamplesSimilar(first, second) ? YoutubeBaselineDecision::Clean : YoutubeBaselineDecision::NeedsThird, reference}; + if (firstClass == YoutubeProbeSampleClass::Marginal && secondClass == YoutubeProbeSampleClass::Marginal && youtubeBaselineSamplesSimilar(first, second)) + return {YoutubeBaselineDecision::Impaired, reference}; + return {YoutubeBaselineDecision::NeedsThird, reference}; +} + +inline YoutubeBaselineAssessment resolveYoutubeBaseline(const YoutubeProbeSampleMetrics &first, const YoutubeProbeSampleMetrics &second, + const YoutubeProbeSampleMetrics &third) +{ + YoutubeProbeSampleMetrics reference{medianOfThree(first.throughputBasisPoints, second.throughputBasisPoints, third.throughputBasisPoints), + medianOfThree(first.dropBasisPoints, second.dropBasisPoints, third.dropBasisPoints), + medianOfThree(first.congestionHighBasisPoints, second.congestionHighBasisPoints, third.congestionHighBasisPoints), + medianOfThree(first.congestionSevereBasisPoints, second.congestionSevereBasisPoints, + third.congestionSevereBasisPoints)}; + const size_t hardSamples = (classifyYoutubeProbeSample(first) == YoutubeProbeSampleClass::Hard ? 1 : 0) + + (classifyYoutubeProbeSample(second) == YoutubeProbeSampleClass::Hard ? 1 : 0) + + (classifyYoutubeProbeSample(third) == YoutubeProbeSampleClass::Hard ? 1 : 0); + if (hardSamples >= 2) + return {YoutubeBaselineDecision::Unstable, reference}; + const YoutubeProbeSampleClass referenceClass = classifyYoutubeProbeSample(reference); + if (referenceClass == YoutubeProbeSampleClass::Hard) + return {YoutubeBaselineDecision::Unstable, reference}; + if (referenceClass == YoutubeProbeSampleClass::Clean) + return {YoutubeBaselineDecision::Clean, reference}; + return {YoutubeBaselineDecision::Impaired, reference}; +} + +inline bool youtubeSampleAccepted(const YoutubeProbeSampleMetrics &sample, const YoutubeBaselineAssessment &baseline) +{ + if (classifyYoutubeProbeSample(sample) == YoutubeProbeSampleClass::Clean) + return true; + if (baseline.decision != YoutubeBaselineDecision::Impaired) + return false; + + const YoutubeProbeSampleMetrics &reference = baseline.reference; + const uint32_t minimumThroughput = + std::max(kYoutubeHardThroughputMinimumBasisPoints, reference.throughputBasisPoints > 500 ? reference.throughputBasisPoints - 500 : 0); + const uint32_t maximumDrops = std::min(kYoutubeHardDropMaximumBasisPoints, reference.dropBasisPoints + 100); + const uint32_t maximumHighCongestion = std::min(kYoutubeHardCongestionHighMaximumBasisPoints, reference.congestionHighBasisPoints + 1000); + const uint32_t maximumSevereCongestion = + std::min(kYoutubeHardCongestionSevereMaximumBasisPoints, reference.congestionSevereBasisPoints + 500); + return sample.throughputBasisPoints >= minimumThroughput && sample.dropBasisPoints <= maximumDrops && + sample.congestionHighBasisPoints <= maximumHighCongestion && sample.congestionSevereBasisPoints <= maximumSevereCongestion; +} + +inline bool youtubeLowControlRecovered(const YoutubeProbeSampleMetrics &control, const YoutubeProbeSampleMetrics &original, + const YoutubeBaselineAssessment &baseline) +{ + if (!youtubeSampleAccepted(control, baseline)) + return false; + return (uint64_t)control.throughputBasisPoints + 500 >= original.throughputBasisPoints && + control.dropBasisPoints <= (uint64_t)original.dropBasisPoints + 100 && + control.congestionHighBasisPoints <= (uint64_t)original.congestionHighBasisPoints + 1000 && + control.congestionSevereBasisPoints <= (uint64_t)original.congestionSevereBasisPoints + 500; +} + +inline YoutubeConfirmationDecision decideYoutubeConfirmation(bool lowControlRecovered, bool highRetryAccepted) +{ + if (lowControlRecovered) + return highRetryAccepted ? YoutubeConfirmationDecision::TransientRecovered : YoutubeConfirmationDecision::CapacityKnee; + return highRetryAccepted ? YoutubeConfirmationDecision::Inconsistent : YoutubeConfirmationDecision::PathUnstable; +} + struct YoutubeRampEvidence { bool passedStep = false; uint64_t recommendationBasisKbps = 0; diff --git a/tests/osn-tests/src/test_osn_auto_optimizer_v1.ts b/tests/osn-tests/src/test_osn_auto_optimizer.ts similarity index 98% rename from tests/osn-tests/src/test_osn_auto_optimizer_v1.ts rename to tests/osn-tests/src/test_osn_auto_optimizer.ts index 65b3735d1..4d34dadfc 100644 --- a/tests/osn-tests/src/test_osn_auto_optimizer_v1.ts +++ b/tests/osn-tests/src/test_osn_auto_optimizer.ts @@ -12,7 +12,7 @@ import * as net from 'net'; import { OBSHandler } from '../util/obs_handler'; import { deleteConfigFiles } from '../util/general'; -const testName = 'osn-auto-optimizer-v1'; +const testName = 'osn-auto-optimizer'; const mockPort = 11937; describe(testName, function() { @@ -138,7 +138,7 @@ describe(testName, function() { legs: [leg()], activeProbes: [{ probeId: 'twitch-primary', - kind: 'twitch-standard-v1', + kind: 'twitch-standard', legId: 'primary', serviceName: 'Twitch', server: `rtmp://127.0.0.1:${mockPort}/live`, @@ -170,7 +170,7 @@ describe(testName, function() { legs: [leg({ destinations: [{ platform: 'youtube' }] })], activeProbes: [{ probeId: 'youtube-primary', - kind: 'youtube-unbound-v1', + kind: 'youtube-unbound', legId: 'primary', serviceName: 'YouTube - RTMPS', server: `rtmps://127.0.0.1:${mockPort}/live2`, @@ -196,7 +196,7 @@ describe(testName, function() { legs: [leg({ destinations: [{ platform: 'youtube' }] })], activeProbes: [{ probeId: 'youtube-missing-service', - kind: 'youtube-unbound-v1', + kind: 'youtube-unbound', legId: 'primary', server: 'rtmps://a.rtmps.youtube.com/live2', streamKey: 'not-a-real-key', @@ -219,7 +219,7 @@ describe(testName, function() { })], activeProbes: [{ probeId: 'cloud-twitch-only', - kind: 'twitch-standard-v1', + kind: 'twitch-standard', legId: 'primary', serviceName: 'Twitch', // The incomplete set must be rejected before any connection @@ -261,7 +261,7 @@ describe(testName, function() { activeProbes: [ { probeId: 'dual-twitch', - kind: 'twitch-standard-v1', + kind: 'twitch-standard', legId: 'horizontal', serviceName: 'Twitch', server: 'rtmp://live.twitch.tv/app', @@ -269,7 +269,7 @@ describe(testName, function() { }, { probeId: 'dual-youtube', - kind: 'youtube-unbound-v1', + kind: 'youtube-unbound', legId: 'vertical', serviceName: 'YouTube - RTMPS', server: 'rtmps://a.rtmps.youtube.com/live2',