From 05245d594965348c73e8374ca98e7bcde8edba79 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Wed, 9 Sep 2026 08:27:26 +1200 Subject: [PATCH 1/8] Fixed saving non-common encoder settings --- js/module.ts | 28 +- obs-studio-client/source/nodeobs_settings.cpp | 38 +- obs-studio-client/source/nodeobs_settings.hpp | 1 + obs-studio-server/source/nodeobs_settings.cpp | 80 ++++ obs-studio-server/source/nodeobs_settings.h | 1 + .../source/osn-advanced-streaming.cpp | 1 + obs-studio-server/source/osn-encoders.cpp | 33 ++ obs-studio-server/source/osn-encoders.hpp | 1 + .../source/osn-simple-streaming.cpp | 48 +-- .../src/test_osn_advanced_recording.ts | 45 +-- .../src/test_osn_encoder_settings.ts | 362 ++++++++++++++++++ tests/osn-tests/util/media_probe.ts | 52 ++- 12 files changed, 601 insertions(+), 89 deletions(-) create mode 100644 tests/osn-tests/src/test_osn_encoder_settings.ts diff --git a/js/module.ts b/js/module.ts index 5bdc72360..c8abc0cdd 100644 --- a/js/module.ts +++ b/js/module.ts @@ -2424,9 +2424,9 @@ interface IAutoOptimizer { } /** - * Typed Auto Optimizer surface on the add-on's otherwise dynamic export. + * Typed methods on the add-on's otherwise dynamic export. */ -interface INodeObs { +export interface INodeObs { [key: string]: any; /** Starts and manages Auto Optimizer runs. */ @@ -2440,6 +2440,30 @@ interface INodeObs { * @throws {Error} If the IPC call fails or OSN returns an error response without an initialization result */ OBS_API_initAPI(options: IOBSAPIInitializationOptions): EVideoCodes; + + /** + * Reads the saved video encoder settings for Factory encoder creation, including encoder defaults. + * Advanced mode includes all saved encoder properties and uses the backup configuration when needed. + * Simple streaming includes its bitrate, enabled advanced options, and encoder preset. Standalone simple + * recording returns encoder defaults; the recording output applies its quality preset when it starts. + * A recording configured to use the stream encoder reads the streaming settings instead. + * Service restrictions remain the responsibility of the output when it starts. + * Advanced selections must use registered OBS IDs after the normal settings migration; this read only + * resolves simple encoder aliases and the existing JIM encoder migration. + * + * This read does not create encoders or outputs, modify configuration files, or change running encoders. + * The returned object is an independent copy with no native lifetime; modifying it does not save settings. + * Missing primary and backup encoder files use encoder defaults. Existing unreadable files cause an error + * when neither the primary file nor its backup can be loaded. + * @param encoderId - Registered OBS video encoder ID matching the saved selection after simple alias or legacy encoder conversion + * @param outputType - Output whose saved video encoder settings to read + * @param mode - Saved output mode, which must match the current configuration + * @returns Settings ready to pass explicitly to VideoEncoderFactory.create + * @throws {TypeError} If arguments are not exactly three strings, the ID is empty, or outputType or mode is unsupported + * @throws {Error} If OBS is not initialized, the encoder is unavailable or does not match the saved selection, + * the mode does not match the saved configuration, existing encoder files cannot be read, or IPC fails + */ + OBS_settings_getEncoderSettings(encoderId: string, outputType: 'streaming' | 'recording', mode: 'Simple' | 'Advanced'): ISettings; } export const enum VCamOutputType { diff --git a/obs-studio-client/source/nodeobs_settings.cpp b/obs-studio-client/source/nodeobs_settings.cpp index 3c2d4534d..1435c297a 100644 --- a/obs-studio-client/source/nodeobs_settings.cpp +++ b/obs-studio-client/source/nodeobs_settings.cpp @@ -133,6 +133,41 @@ std::vector serializeCategory(uint32_t subCategoriesCount return category; } +Napi::Value settings::OBS_settings_getEncoderSettings(const Napi::CallbackInfo &info) +{ + if (info.Length() != 3 || !info[0].IsString() || !info[1].IsString() || !info[2].IsString()) { + Napi::TypeError::New(info.Env(), "OBS_settings_getEncoderSettings expects encoder ID, output type, and mode strings") + .ThrowAsJavaScriptException(); + return info.Env().Undefined(); + } + + std::string encoderId = info[0].As().Utf8Value(); + std::string outputType = info[1].As().Utf8Value(); + std::string mode = info[2].As().Utf8Value(); + if (encoderId.empty() || (outputType != "streaming" && outputType != "recording") || (mode != "Simple" && mode != "Advanced")) { + Napi::TypeError::New(info.Env(), + "OBS_settings_getEncoderSettings requires a nonempty encoder ID, streaming or recording, and Simple or Advanced") + .ThrowAsJavaScriptException(); + return info.Env().Undefined(); + } + + auto conn = GetConnection(info); + if (!conn) + return info.Env().Undefined(); + + auto response = conn->call_synchronous_helper("Settings", "OBS_settings_getEncoderSettings", {encoderId, outputType, mode}); + if (!ValidateResponse(info, response)) + return info.Env().Undefined(); + if (response.size() != 2 || response[1].type != ipc::type::String) { + Napi::Error::New(info.Env(), "Invalid encoder settings response").ThrowAsJavaScriptException(); + return info.Env().Undefined(); + } + + Napi::Object json = info.Env().Global().Get("JSON").As(); + Napi::Function parse = json.Get("parse").As(); + return parse.Call(json, {Napi::String::New(info.Env(), response[1].value_str)}); +} + Napi::Value settings::OBS_settings_getSettings(const Napi::CallbackInfo &info) { std::string category = info[0].ToString().Utf8Value(); @@ -539,6 +574,7 @@ void settings::OBS_settings_setEnhancedBroadcasting(const Napi::CallbackInfo &in void settings::Init(Napi::Env env, Napi::Object exports) { exports.Set(Napi::String::New(env, "OBS_settings_getSettings"), Napi::Function::New(env, settings::OBS_settings_getSettings)); + exports.Set(Napi::String::New(env, "OBS_settings_getEncoderSettings"), Napi::Function::New(env, settings::OBS_settings_getEncoderSettings)); exports.Set(Napi::String::New(env, "OBS_settings_saveSettings"), Napi::Function::New(env, settings::OBS_settings_saveSettings)); exports.Set(Napi::String::New(env, "OBS_settings_isValidEncoder"), Napi::Function::New(env, settings::OBS_settings_isValidEncoder)); exports.Set(Napi::String::New(env, "OBS_settings_getListCategories"), Napi::Function::New(env, settings::OBS_settings_getListCategories)); @@ -547,4 +583,4 @@ void settings::Init(Napi::Env env, Napi::Object exports) exports.Set(Napi::String::New(env, "OBS_settings_getVideoDevices"), Napi::Function::New(env, settings::OBS_settings_getVideoDevices)); exports.Set(Napi::String::New(env, "OBS_settings_isEnhancedBroadcasting"), Napi::Function::New(env, settings::OBS_settings_isEnhancedBroadcasting)); exports.Set(Napi::String::New(env, "OBS_settings_setEnhancedBroadcasting"), Napi::Function::New(env, settings::OBS_settings_setEnhancedBroadcasting)); -} \ No newline at end of file +} diff --git a/obs-studio-client/source/nodeobs_settings.hpp b/obs-studio-client/source/nodeobs_settings.hpp index 174e580a8..b0c522431 100644 --- a/obs-studio-client/source/nodeobs_settings.hpp +++ b/obs-studio-client/source/nodeobs_settings.hpp @@ -132,6 +132,7 @@ struct SubCategory { void Init(Napi::Env env, Napi::Object exports); Napi::Value OBS_settings_getSettings(const Napi::CallbackInfo &info); +Napi::Value OBS_settings_getEncoderSettings(const Napi::CallbackInfo &info); void OBS_settings_saveSettings(const Napi::CallbackInfo &info); Napi::Value OBS_settings_isValidEncoder(const Napi::CallbackInfo &info); diff --git a/obs-studio-server/source/nodeobs_settings.cpp b/obs-studio-server/source/nodeobs_settings.cpp index df16945a6..e4bef4386 100644 --- a/obs-studio-server/source/nodeobs_settings.cpp +++ b/obs-studio-server/source/nodeobs_settings.cpp @@ -71,6 +71,9 @@ void OBS_settings::Register(ipc::server &srv) cls->register_function( std::make_shared("OBS_settings_getSettings", std::vector{ipc::type::String}, OBS_settings_getSettings)); + cls->register_function(std::make_shared("OBS_settings_getEncoderSettings", + std::vector{ipc::type::String, ipc::type::String, ipc::type::String}, + OBS_settings_getEncoderSettings)); cls->register_function(std::make_shared( "OBS_settings_saveSettings", std::vector{ipc::type::String, ipc::type::UInt32, ipc::type::UInt32, ipc::type::Binary}, OBS_settings_saveSettings)); @@ -89,6 +92,83 @@ void OBS_settings::Register(ipc::server &srv) srv.register_collection(cls); } +void OBS_settings::OBS_settings_getEncoderSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval) +{ + const std::string &encoderId = args[0].value_str; + const std::string &outputType = args[1].value_str; + const std::string &mode = args[2].value_str; + if (!obs_initialized()) { + PRETTY_ERROR_RETURN(ErrorCode::Error, "OBS must be initialized before reading encoder settings."); + } + if ((mode != "Simple" && mode != "Advanced") || (outputType != "streaming" && outputType != "recording")) { + PRETTY_ERROR_RETURN(ErrorCode::Error, "Invalid encoder settings mode or output type."); + } + if (!osn::EncoderUtils::isEncoderRegistered(encoderId) || obs_get_encoder_type(encoderId.c_str()) != OBS_ENCODER_VIDEO) { + PRETTY_ERROR_RETURN(ErrorCode::Error, "Encoder settings require a registered video encoder ID."); + } + + config_t *config = ConfigManager::getInstance().getBasic(); + if (mode != utility::GetSafeString(config_get_string(config, "Output", "Mode"))) { + PRETTY_ERROR_RETURN(ErrorCode::Error, "Requested encoder settings mode does not match the saved output mode."); + } + bool simple = mode == "Simple"; + bool recording = outputType == "recording"; + const char *section = simple ? "SimpleOutput" : "AdvOut"; + std::string selected = utility::GetSafeString(config_get_string(config, section, recording ? "RecEncoder" : (simple ? "StreamEncoder" : "Encoder"))); + if (recording && + ((simple && strcmp(utility::GetSafeString(config_get_string(config, section, "RecQuality")), "Stream") == 0) || (!simple && selected == "none"))) { + recording = false; + selected = utility::GetSafeString(config_get_string(config, section, simple ? "StreamEncoder" : "Encoder")); + } + // Match the existing JIM encoder migration without changing the saved selection during a read. + if (osn::EncoderUtils::isOldJimNvencEncoder(selected)) + selected = ENCODER_NVENC_H264_TEX; + + std::string selectedId = selected; + if (simple) { + bool found = false; + for (const auto &encoder : osn::EncoderUtils::videoEncoderOptions) { + if (!encoder.simple_name.empty() && encoder.simple_name == selected) { + found = true; + break; + } + } + if (!found) { + PRETTY_ERROR_RETURN(ErrorCode::Error, "Saved simple encoder selection is invalid."); + } + selectedId = osn::EncoderUtils::getInternalEncoderFromSimple(selected.c_str()); + } + if (selectedId != encoderId) { + PRETTY_ERROR_RETURN(ErrorCode::Error, "Requested encoder does not match the saved output encoder."); + } + + OBSDataAutoRelease settings = obs_encoder_defaults(encoderId.c_str()); + if (simple) { + // Simple recording quality and service restrictions are applied by the output when it starts. + if (!recording) { + OBSDataAutoRelease simpleSettings = osn::EncoderUtils::getSimpleStreamingEncoderSettings(selected.c_str()); + obs_data_apply(settings, simpleSettings); + } + } else { + std::string path = recording ? ConfigManager::getInstance().getRecord() : ConfigManager::getInstance().getStream(); + OBSDataAutoRelease savedSettings = obs_data_create_from_json_file(path.c_str()); + // The safe file loader restores the backup by renaming it. Read it directly to keep this API read-only. + if (!savedSettings) + savedSettings = obs_data_create_from_json_file((path + ".bak").c_str()); + if (!savedSettings && (os_file_exists(path.c_str()) || os_file_exists((path + ".bak").c_str()))) { + PRETTY_ERROR_RETURN(ErrorCode::Error, "Cannot read saved encoder settings or their backup."); + } + if (savedSettings) { + osn::EncoderUtils::updateNvencPresets(savedSettings, encoderId.c_str()); + obs_data_apply(settings, savedSettings); + } + } + + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); + rval.push_back(ipc::value(obs_data_get_json_with_defaults(settings))); + AUTO_DEBUG; +} + void OBS_settings::OBS_settings_getSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval) { std::string nameCategory = args[0].value_str; diff --git a/obs-studio-server/source/nodeobs_settings.h b/obs-studio-server/source/nodeobs_settings.h index 70a8ea6f7..16db6bcbc 100644 --- a/obs-studio-server/source/nodeobs_settings.h +++ b/obs-studio-server/source/nodeobs_settings.h @@ -147,6 +147,7 @@ class OBS_settings { static void Register(ipc::server &); static void OBS_settings_getSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval); + static void OBS_settings_getEncoderSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval); static void OBS_settings_saveSettings(void *data, const int64_t id, const std::vector &args, std::vector &rval); static void OBS_settings_isValidEncoder(void *data, const int64_t id, const std::vector &args, std::vector &rval); diff --git a/obs-studio-server/source/osn-advanced-streaming.cpp b/obs-studio-server/source/osn-advanced-streaming.cpp index 943de15ed..5de81ecec 100644 --- a/obs-studio-server/source/osn-advanced-streaming.cpp +++ b/obs-studio-server/source/osn-advanced-streaming.cpp @@ -283,6 +283,7 @@ static bool setAudioEncoder(osn::AdvancedStreaming *streaming) obs_data_t *settings = obs_data_create(); obs_data_set_int(settings, "bitrate", audioTrack->bitrate); obs_encoder_update(streaming->audioEncoder, settings); + obs_encoder_set_name(streaming->audioEncoder, audioTrack->name.empty() ? "audio-encoder-streaming" : audioTrack->name.c_str()); obs_data_release(settings); } } diff --git a/obs-studio-server/source/osn-encoders.cpp b/obs-studio-server/source/osn-encoders.cpp index 65351e3cc..ae88a33ca 100644 --- a/obs-studio-server/source/osn-encoders.cpp +++ b/obs-studio-server/source/osn-encoders.cpp @@ -430,6 +430,39 @@ std::string osn::EncoderUtils::getPublicEncoderTitle(const char *encoder) return {}; } +obs_data_t *osn::EncoderUtils::getSimpleStreamingEncoderSettings(const char *encoder) +{ + config_t *config = ConfigManager::getInstance().getBasic(); + obs_data_t *settings = obs_data_create(); + obs_data_set_string(settings, "rate_control", "CBR"); + obs_data_set_int(settings, "bitrate", config_get_uint(config, "SimpleOutput", "VBitrate")); + + if (config_get_bool(config, "SimpleOutput", "UseAdvanced")) { + std::string presetType = getEncoderPreset(encoder); + const char *preset = utility::GetSafeString(config_get_string(config, "SimpleOutput", presetType.c_str())); + if (presetType == PRESET_NVENC && strlen(preset) == 0) { + const char *oldPreset = utility::GetSafeString(config_get_string(config, "SimpleOutput", PRESET_NVENC_DEP)); + if (strlen(oldPreset) != 0) + preset = convertNvencSimplePreset(oldPreset); + } + + std::string encoderId = getInternalEncoderFromSimple(encoder); + const char *presetProperty = "preset"; + if (presetType == PRESET_QSV) + presetProperty = "target_usage"; + else if (presetType == PRESET_NVENC && encoderId.compare(0, 7, "ffmpeg_") == 0) + presetProperty = "preset2"; + if (strlen(preset) != 0) + obs_data_set_string(settings, presetProperty, preset); + obs_data_set_string(settings, "x264opts", utility::GetSafeString(config_get_string(config, "SimpleOutput", "x264Settings"))); + } + + if (getEncoderFamily(encoder) == FAMILY_APPLE) + obs_data_set_string(settings, "profile", utility::GetSafeString(config_get_string(config, "SimpleOutput", "Profile"))); + + return settings; +} + bool osn::EncoderUtils::isOldJimNvencEncoder(const std::string &encoderId) { return encoderId == ENCODER_JIM_NVENC || encoderId == ENCODER_JIM_HEVC_NVENC || encoderId == ENCODER_JIM_AV1_NVENC; diff --git a/obs-studio-server/source/osn-encoders.hpp b/obs-studio-server/source/osn-encoders.hpp index 450a3f4d5..b3e1558f4 100644 --- a/obs-studio-server/source/osn-encoders.hpp +++ b/obs-studio-server/source/osn-encoders.hpp @@ -115,6 +115,7 @@ std::string getEncoderPreset(const char *encoder); // backend-only family constants such as FAMILY_QSV. std::string getPublicEncoderFamily(const char *encoder); std::string getPublicEncoderTitle(const char *encoder); +obs_data_t *getSimpleStreamingEncoderSettings(const char *encoder); bool isOldJimNvencEncoder(const std::string &encoderId); void convertOldJimNvencEncoder(config_t *config, const std::string &configSection, const std::string &streamEncoderSetting, const std::string &recordingEncoderSetting); diff --git a/obs-studio-server/source/osn-simple-streaming.cpp b/obs-studio-server/source/osn-simple-streaming.cpp index 37cd12f31..8adde838d 100644 --- a/obs-studio-server/source/osn-simple-streaming.cpp +++ b/obs-studio-server/source/osn-simple-streaming.cpp @@ -252,10 +252,11 @@ static void StopTwitchSoundtrackAudio(osn::Streaming *streaming) obs_source_release(desktopSource2); } -void UpdateStreamingSettings_amd(obs_data_t *settings, int bitrate) +void UpdateStreamingSettings_amd(obs_data_t *settings, int bitrate, bool useAdvanced) { obs_data_set_string(settings, "profile", "high"); - obs_data_set_string(settings, "preset", "quality"); + if (!useAdvanced) + obs_data_set_string(settings, "preset", "quality"); obs_data_set_string(settings, "rate_control", "CBR"); obs_data_set_int(settings, "bitrate", bitrate); obs_data_set_int(settings, "keyint_sec", 2); @@ -281,7 +282,7 @@ void osn::SimpleStreaming::updateEncoders() std::string id = obs_encoder_get_id(videoEncoder); if (id.compare(ADVANCED_ENCODER_AMD) == 0) - UpdateStreamingSettings_amd(videoEncSettings, vBitrate); + UpdateStreamingSettings_amd(videoEncSettings, vBitrate, useAdvanced); obs_data_set_string(videoEncSettings, "rate_control", "CBR"); obs_data_set_int(videoEncSettings, "bitrate", vBitrate); @@ -458,48 +459,9 @@ obs_encoder_t *osn::ISimpleStreaming::CreateLegacyVideoEncoder() config_save_safe(ConfigManager::getInstance().getBasic(), "tmp", nullptr); } - obs_data_t *videoEncData = obs_data_create(); - obs_data_set_string(videoEncData, "rate_control", "CBR"); - obs_data_set_int(videoEncData, "bitrate", config_get_uint(ConfigManager::getInstance().getBasic(), "SimpleOutput", "VBitrate")); - - bool advanced = config_get_bool(ConfigManager::getInstance().getBasic(), "SimpleOutput", "UseAdvanced"); - const char *custom = utility::GetSafeString(config_get_string(ConfigManager::getInstance().getBasic(), "SimpleOutput", "x264Settings")); - - const char *preset = nullptr; - - std::string presetType = osn::EncoderUtils::getEncoderPreset(encId); + obs_data_t *videoEncData = osn::EncoderUtils::getSimpleStreamingEncoderSettings(encId); std::string encIdOBS = osn::EncoderUtils::getInternalEncoderFromSimple(encId); - preset = utility::GetSafeString(config_get_string(ConfigManager::getInstance().getBasic(), "SimpleOutput", presetType.c_str())); - - if (presetType == PRESET_NVENC) { - if (strlen(preset) == 0) { - const char *oldParamName = PRESET_NVENC_DEP; - const char *oldValue = utility::GetSafeString(config_get_string(ConfigManager::getInstance().getBasic(), "SimpleOutput", oldParamName)); - if (strlen(oldValue) != 0) { - preset = osn::EncoderUtils::convertNvencSimplePreset(oldValue); - blog(LOG_INFO, "NVENC preset converted from %s to %s", oldValue, preset); - } - } - } - - if (advanced) { - obs_data_set_string(videoEncData, "preset", preset); - obs_data_set_string(videoEncData, "x264opts", custom); - } - - bool enforceServiceBitrate = config_get_bool(ConfigManager::getInstance().getBasic(), "SimpleOutput", "EnforceBitrate"); - - if (advanced && !enforceServiceBitrate) { - obs_data_set_int(videoEncData, "bitrate", config_get_uint(ConfigManager::getInstance().getBasic(), "SimpleOutput", "VBitrate")); - } - - if (osn::EncoderUtils::getEncoderFamily(encId) == FAMILY_APPLE) { - const char *profile = utility::GetSafeString(config_get_string(ConfigManager::getInstance().getBasic(), "SimpleOutput", "Profile")); - if (profile) - obs_data_set_string(videoEncData, "profile", profile); - } - obs_encoder_t *videoEncoder = obs_video_encoder_create(encIdOBS.c_str(), "video-encoder", videoEncData, nullptr); obs_data_release(videoEncData); diff --git a/tests/osn-tests/src/test_osn_advanced_recording.ts b/tests/osn-tests/src/test_osn_advanced_recording.ts index 37d55ebb9..7a3049322 100644 --- a/tests/osn-tests/src/test_osn_advanced_recording.ts +++ b/tests/osn-tests/src/test_osn_advanced_recording.ts @@ -8,52 +8,13 @@ import { deleteConfigFiles, sleep } from '../util/general'; import { EOBSInputTypes, EOBSOutputSignal, EOBSOutputType } from '../util/obs_enums'; import { ERecordingFormat, ERecordingQuality } from '../osn'; import * as inputSettings from '../util/input_settings'; -import { getMeanVolumeDb } from '../util/media_probe'; +import { getAudioStreamTitles, getMeanVolumeDb } from '../util/media_probe'; import * as path from 'path'; const fs = require('fs'); -const childProcess = require('child_process'); const testName = 'osn-advanced-recording'; const customFilenamePattern = '%CCYY-%MM-%DD_%hh-%mm-%ss-%s-%%'; -function getFfprobePath() { - const executable = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'; - const packagedFfprobe = path.join(path.normalize(osn.wd), executable); - const bundledFfprobe = path.join( - path.normalize(__dirname), - '..', - '..', - '..', - 'build', - 'libobs-src', - 'bin', - process.arch === 'x64' ? '64bit' : '32bit', - executable, - ); - - return [packagedFfprobe, bundledFfprobe].find(ffprobePath => fs.existsSync(ffprobePath)) || executable; -} - -function getAudioStreamTitles(filePath: string): string[] { - const output = childProcess.execFileSync( - getFfprobePath(), - [ - '-v', - 'error', - '-select_streams', - 'a', - '-show_entries', - 'stream_tags=title', - '-of', - 'json', - filePath, - ], - { encoding: 'utf8' }, - ); - const probe = JSON.parse(output); - return (probe.streams || []).map((stream: { tags?: { title?: string } }) => stream.tags?.title || ''); -} - describe(testName, () => { let obs: OBSHandler; let hasTestFailed: boolean = false; @@ -462,7 +423,7 @@ describe(testName, () => { } }); - it('Audio track uses configured bitrate after binding to an OBS encoder', function () { + it('Audio track retains its configured bitrate in the track registry', function () { if (obs.isDarwin()) { this.skip(); } @@ -474,7 +435,7 @@ describe(testName, () => { expect(osn.AudioTrackFactory.getAtIndex(1).bitrate).to.equal( audioTrackBitrate, - 'Audio track encoder did not use the configured bitrate', + 'Audio track registry did not retain the configured bitrate', ); }); diff --git a/tests/osn-tests/src/test_osn_encoder_settings.ts b/tests/osn-tests/src/test_osn_encoder_settings.ts new file mode 100644 index 000000000..b64fee727 --- /dev/null +++ b/tests/osn-tests/src/test_osn_encoder_settings.ts @@ -0,0 +1,362 @@ +import 'mocha'; +import { expect } from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as osn from '../osn'; +import { OBSHandler } from '../util/obs_handler'; +import { deleteConfigFiles, sleep } from '../util/general'; +import { EOBSOutputSignal, EOBSOutputType, EOBSSettingsCategories } from '../util/obs_enums'; +import { getAudioStreamBitrates, getVideoKeyframes } from '../util/media_probe'; +import { logInfo, logEmptyLine } from '../util/logger'; + +const testName = 'osn-encoder-settings'; +const configPath = path.join(__dirname, '..', 'osnData', 'slobs-client'); +const outputCategory = EOBSSettingsCategories.Output; + +// Broadband stereo audio makes the measured AAC bitrate meaningful. Silence +// and a single sine wave can encode far below the requested bitrate. +function createTestAudio(filePath: string) { + const sampleRate = 48000; + const sampleCount = sampleRate * 8; + const wav = Buffer.alloc(44 + sampleCount * 4); + wav.write('RIFF'); + wav.writeUInt32LE(wav.length - 8, 4); + wav.write('WAVEfmt ', 8); + wav.writeUInt32LE(16, 16); + wav.writeUInt16LE(1, 20); + wav.writeUInt16LE(2, 22); + wav.writeUInt32LE(sampleRate, 24); + wav.writeUInt32LE(sampleRate * 4, 28); + wav.writeUInt16LE(4, 32); + wav.writeUInt16LE(16, 34); + wav.write('data', 36); + wav.writeUInt32LE(wav.length - 44, 40); + let seed = 12345; + for (let i = 0; i < sampleCount * 2; i++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + wav.writeInt16LE(Math.floor((seed / 0x100000000 - 0.5) * 16000), 44 + i * 2); + } + fs.writeFileSync(filePath, wav); +} + +describe(testName, function () { + let obs: OBSHandler; + let hasTestFailed = false; + + before(function () { + logInfo(testName, 'Starting ' + testName + ' tests'); + deleteConfigFiles(); + obs = new OBSHandler(testName); + obs.defaultVideoContext.video = { + ...obs.defaultVideoContext.video, + baseWidth: 320, baseHeight: 180, outputWidth: 320, outputHeight: 180, + }; + }); + + beforeEach(function () { + obs.setSetting(outputCategory, 'Mode', 'Advanced'); + obs.setSetting(outputCategory, 'Encoder', 'obs_x264'); + obs.setSetting(outputCategory, 'RecEncoder', 'obs_x264'); + obs.setSetting(outputCategory, 'RecAEncoder', 'ffmpeg_aac'); + obs.setSetting(outputCategory, 'ApplyServiceSettings', false); + }); + + afterEach(function () { + hasTestFailed = this.currentTest.state === 'failed' || hasTestFailed; + }); + + after(async function () { + if (obs) { + obs.shutdown(); + if (hasTestFailed) await obs.uploadTestCache(); + } + // IPC disconnect returns before the server closes its media files. + const deadline = Date.now() + 5000; + while (true) { + try { + deleteConfigFiles(); + break; + } catch (error) { + if (Date.now() >= deadline) throw error; + await sleep(100); + } + } + logInfo(testName, 'Finished ' + testName + ' tests'); + logEmptyLine(); + }); + + function saveSettings(values: osn.ISettings) { + const settings = obs.getSettingsContainer(outputCategory); + const parameters = settings.reduce((parameters, section) => parameters.concat(section.parameters), []); + for (const name of Object.keys(values)) { + const parameter = parameters.find(parameter => parameter.name === name); + expect(parameter, `Missing output setting ${name}`).to.not.equal(undefined); + parameter.currentValue = values[name]; + } + obs.setSettingsContainer(outputCategory, settings); + } + + function saveEncoderSettings(outputType: 'streaming' | 'recording', values: osn.ISettings) { + const settings: osn.ISettings = {}; + for (const name of Object.keys(values)) { + settings[outputType === 'recording' ? `Rec${name}` : name] = values[name]; + } + saveSettings(settings); + } + + it('Rejects invalid arguments, stale mode and mismatched encoder selections', function () { + const getSettings = osn.NodeObs.OBS_settings_getEncoderSettings as (...args: any[]) => osn.ISettings; + for (const args of [ + [], ['obs_x264', 'recording'], [null, 'recording', 'Advanced'], + ['', 'recording', 'Advanced'], ['obs_x264', 'replay', 'Advanced'], + ['obs_x264', 'recording', 'advanced'], + ]) { + expect(() => getSettings(...args)).to.throw(TypeError); + } + expect(() => getSettings('missing-encoder', 'recording', 'Advanced')).to.throw(Error); + expect(() => getSettings('ffmpeg_aac', 'recording', 'Advanced')).to.throw(Error); + expect(() => getSettings('obs_x264', 'recording', 'Simple')).to.throw(Error); + + const otherEncoder = osn.VideoEncoderFactory.types().find(id => id !== 'obs_x264'); + expect(otherEncoder, 'Expected an additional registered video encoder').to.not.equal(undefined); + expect(() => getSettings(otherEncoder, 'recording', 'Advanced')).to.throw(Error); + }); + + for (const outputType of ['streaming', 'recording'] as const) { + it(`Passes all saved advanced ${outputType} settings into a Factory encoder`, function () { + const values = { + rate_control: 'CRF', bitrate: 3100, crf: 18, keyint_sec: 1, + preset: 'fast', profile: 'high', tune: '', x264opts: 'scenecut=0', + use_bufsize: false, buffer_size: 0, repeat_headers: false, + }; + saveEncoderSettings(outputType, values); + const settingsPath = path.join(configPath, outputType === 'recording' ? 'recordEncoder.json' : 'streamEncoder.json'); + const saved = fs.readFileSync(settingsPath, 'utf8'); + const settings = osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', outputType, 'Advanced'); + expect(fs.readFileSync(settingsPath, 'utf8')).to.equal(saved); + expect(settings).to.deep.include(values); + expect(settings).to.not.have.property('RecEncoder'); + expect(settings).to.not.have.property('RecFilePath'); + expect(settings).to.not.have.property('Reckeyint_sec'); + + const encoder = osn.VideoEncoderFactory.create('obs_x264', `saved-${outputType}`, settings); + const defaultEncoder = osn.VideoEncoderFactory.create('obs_x264', `default-${outputType}`, {}); + try { + expect(encoder.settings).to.deep.include(values); + expect(defaultEncoder.settings).to.deep.equal({}); + settings.keyint_sec = 9; + expect(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', outputType, 'Advanced')) + .to.have.property('keyint_sec', 1); + expect(encoder.settings).to.have.property('keyint_sec', 1); + } finally { + encoder.release(); + defaultEncoder.release(); + } + }); + } + + it('Uses the saved streaming settings when recording shares the stream encoder', function () { + saveEncoderSettings('streaming', { keyint_sec: 2, preset: 'faster' }); + saveEncoderSettings('recording', { keyint_sec: 1, preset: 'fast' }); + obs.setSetting(outputCategory, 'RecEncoder', 'none'); + expect(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Advanced')) + .to.deep.equal(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'streaming', 'Advanced')); + }); + + it('Reads the complete backup without changing files and restores defaults when settings are absent', function () { + const filePath = path.join(configPath, 'recordEncoder.json'); + const backupPath = `${filePath}.bak`; + const original = fs.readFileSync(filePath); + const originalBackup = fs.existsSync(backupPath) ? fs.readFileSync(backupPath) : undefined; + const backup = JSON.stringify({ + keyint_sec: 3, custom_boolean: false, custom_integer: 0, + custom_number: 2.5, custom_string: '', custom_object: { enabled: false }, + custom_array: [{ value: 0 }, { value: '' }], + }); + try { + fs.writeFileSync(filePath, '{invalid json'); + fs.writeFileSync(backupPath, backup); + const settings = osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Advanced'); + expect(settings).to.deep.include(JSON.parse(backup)); + expect(settings).to.have.property('preset', 'veryfast'); + expect(fs.readFileSync(filePath, 'utf8')).to.equal('{invalid json'); + expect(fs.readFileSync(backupPath, 'utf8')).to.equal(backup); + + fs.writeFileSync(backupPath, '{invalid backup'); + expect(() => osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Advanced')) + .to.throw(Error); + expect(fs.readFileSync(filePath, 'utf8')).to.equal('{invalid json'); + expect(fs.readFileSync(backupPath, 'utf8')).to.equal('{invalid backup'); + + fs.unlinkSync(filePath); + fs.unlinkSync(backupPath); + const defaults = osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Advanced'); + expect(defaults).to.include({ keyint_sec: 0, preset: 'veryfast', crf: 23 }); + expect(fs.existsSync(filePath)).to.equal(false); + expect(fs.existsSync(backupPath)).to.equal(false); + } finally { + fs.writeFileSync(filePath, original); + if (originalBackup) fs.writeFileSync(backupPath, originalBackup); + else if (fs.existsSync(backupPath)) fs.unlinkSync(backupPath); + } + }); + + it('Maps simple streaming settings and leaves standalone recording quality to the output', function () { + obs.setSetting(outputCategory, 'Mode', 'Simple'); + obs.setSetting(outputCategory, 'StreamEncoder', 'x264'); + obs.setSetting(outputCategory, 'UseAdvanced', true); + saveSettings({ VBitrate: 3100, Preset: 'faster', x264Settings: 'scenecut=0' }); + expect(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'streaming', 'Simple')) + .to.include({ bitrate: 3100, preset: 'faster', x264opts: 'scenecut=0', rate_control: 'CBR' }); + + obs.setSetting(outputCategory, 'RecQuality', 'Stream'); + expect(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Simple')) + .to.deep.equal(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'streaming', 'Simple')); + + obs.setSetting(outputCategory, 'RecQuality', 'HQ'); + obs.setSetting(outputCategory, 'RecEncoder', 'x264'); + expect(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Simple')) + .to.include({ keyint_sec: 0, preset: 'veryfast', crf: 23 }); + + obs.setSetting(outputCategory, 'UseAdvanced', false); + expect(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'streaming', 'Simple')) + .to.include({ bitrate: 3100, preset: 'veryfast', x264opts: '' }); + }); + + it('Keeps the configured AMD preset when simple streaming encoders start', async function () { + const encoderId = 'h264_texture_amf'; + if (osn.VideoEncoderFactory.types().indexOf(encoderId) === -1) this.skip(); + obs.setSetting(outputCategory, 'Mode', 'Simple'); + obs.setSetting(outputCategory, 'StreamEncoder', 'amd'); + obs.setSetting(outputCategory, 'UseAdvanced', true); + saveSettings({ AMDPreset: 'speed' }); + const settings = osn.NodeObs.OBS_settings_getEncoderSettings(encoderId, 'streaming', 'Simple'); + const encoder = osn.VideoEncoderFactory.create(encoderId, 'simple-amd-preset', settings); + const audioEncoder = osn.AudioEncoderFactory.create('ffmpeg_aac', 'simple-amd-audio'); + const service = osn.ServiceFactory.create('rtmp_custom', 'simple-amd-service'); + const streaming = osn.SimpleStreamingFactory.create(); + const recording = osn.SimpleRecordingFactory.create(); + try { + streaming.videoEncoder = encoder; + streaming.audioEncoder = audioEncoder; + streaming.service = service; + streaming.video = obs.defaultVideoContext; + streaming.useAdvanced = true; + streaming.enforceServiceBitrate = false; + recording.path = configPath; + recording.fileFormat = 'simple-amd-preset'; + recording.overwrite = true; + recording.video = obs.defaultVideoContext; + recording.quality = osn.ERecordingQuality.Stream; + recording.streaming = streaming; + recording.signalHandler = signal => obs.signals.push(signal); + recording.start(); + const started = await obs.getNextSignalInfo(EOBSOutputType.Recording, EOBSOutputSignal.Start); + expect(started.signal, started.error).to.equal(EOBSOutputSignal.Start); + expect(started.code, started.error).to.equal(0); + expect(encoder.settings).to.have.property('preset', 'speed'); + await sleep(500); + recording.stop(); + const wrote = await obs.getNextSignalInfo(EOBSOutputType.Recording, EOBSOutputSignal.Wrote); + expect(wrote.signal, wrote.error).to.equal(EOBSOutputSignal.Wrote); + expect(wrote.code, wrote.error).to.equal(0); + } finally { + osn.SimpleRecordingFactory.destroy(recording); + osn.SimpleStreamingFactory.destroy(streaming); + osn.ServiceFactory.destroy(service); + audioEncoder.release(); + encoder.release(); + } + }); + + for (const outputType of ['streaming', 'recording'] as const) { + it(`Encodes saved ${outputType} keyframes and track bitrates after settings change`, async function () { + this.timeout(80000); + const audioPath = path.join(configPath, `encoder-settings-${outputType}.wav`); + createTestAudio(audioPath); + const source = osn.InputFactory.create('ffmpeg_source', 'encoder-settings-audio', { + local_file: audioPath, is_local_file: true, looping: true, + restart_on_activate: true, close_when_inactive: false, + }); + source.audioMixers = 3; + const scene = osn.SceneFactory.create('encoder-settings-scene'); + const sceneItem = scene.add(source); + osn.Global.setOutputSource(1, scene); + const tracks = [osn.AudioTrackFactory.create(160, 'track1'), osn.AudioTrackFactory.create(160, 'track2')]; + tracks.forEach((track, index) => osn.AudioTrackFactory.setAtIndex(track, index + 1)); + try { + await sleep(500); + for (const keyint of [1, 2]) { + saveEncoderSettings(outputType, { + rate_control: 'CBR', bitrate: 2500, keyint_sec: keyint, + preset: 'fast', profile: 'high', x264opts: 'scenecut=0', + }); + const bitrates = keyint === 1 ? [320, 160] : [160, 320]; + saveSettings({ Track1Bitrate: bitrates[0], Track2Bitrate: bitrates[1] }); + tracks.forEach((track, index) => { + track.bitrate = obs.getSetting(outputCategory, `Track${index + 1}Bitrate`); + }); + const settings = osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', outputType, 'Advanced'); + const encoder = osn.VideoEncoderFactory.create('obs_x264', `record-${outputType}-${keyint}`, settings); + const recording = osn.AdvancedRecordingFactory.create(); + const streaming = outputType === 'streaming' ? osn.AdvancedStreamingFactory.create() : undefined; + recording.path = configPath; + recording.fileFormat = `${outputType}-${keyint}`; + recording.format = osn.ERecordingFormat.MP4; + recording.overwrite = true; + recording.video = obs.defaultVideoContext; + recording.videoEncoder = encoder; + recording.mixer = 3; + recording.useStreamEncoders = !!streaming; + recording.signalHandler = signal => obs.signals.push(signal); + if (streaming) { + streaming.videoEncoder = encoder; + streaming.video = obs.defaultVideoContext; + streaming.enforceServiceBitrate = false; + recording.streaming = streaming; + } + try { + expect(encoder.settings).to.include({ keyint_sec: keyint, preset: 'fast', profile: 'high' }); + recording.start(); + const started = await obs.getNextSignalInfo(EOBSOutputType.Recording, EOBSOutputSignal.Start); + expect(started.signal, started.error).to.equal(EOBSOutputSignal.Start); + expect(started.code, started.error).to.equal(0); + await sleep(6200); + recording.stop(); + const stopped = await obs.getNextSignalInfo(EOBSOutputType.Recording, EOBSOutputSignal.Stop); + expect(stopped.code, stopped.error).to.equal(0); + const wrote = await obs.getNextSignalInfo(EOBSOutputType.Recording, EOBSOutputSignal.Wrote); + expect(wrote.signal, wrote.error).to.equal(EOBSOutputSignal.Wrote); + expect(wrote.code, wrote.error).to.equal(0); + + const mediaFile = recording.lastFile(); + const keyframes = getVideoKeyframes(mediaFile); + expect(keyframes.frameRate).to.equal(60); + expect(keyframes.duration).to.be.greaterThan(5.5); + expect(keyframes.frameCount).to.be.greaterThan(300); + expect(keyframes.times.length).to.be.at.least(keyint === 1 ? 6 : 3); + const gaps = keyframes.times.slice(1).map((time, index) => time - keyframes.times[index]); + expect(Math.max(...gaps)).to.be.at.most(keyint + 1 / 60); + // This scene has no cuts: the second recording must also reflect the new interval. + expect(Math.max(...gaps)).to.be.at.least(keyint - 1 / 60); + const audioStreams = getAudioStreamBitrates(mediaFile); + expect(audioStreams.length).to.equal(2); + audioStreams.forEach((audio, index) => { + expect(audio.codec).to.equal('aac'); + expect(audio.bitrate).to.be.closeTo(bitrates[index] * 1000, bitrates[index] * 150); + }); + } finally { + osn.AdvancedRecordingFactory.destroy(recording); + if (streaming) osn.AdvancedStreamingFactory.destroy(streaming); + encoder.release(); + } + } + } finally { + osn.Global.setOutputSource(1, null); + sceneItem.remove(); + scene.release(); + source.release(); + } + }); + } +}); diff --git a/tests/osn-tests/util/media_probe.ts b/tests/osn-tests/util/media_probe.ts index 0c5584776..451f6386e 100644 --- a/tests/osn-tests/util/media_probe.ts +++ b/tests/osn-tests/util/media_probe.ts @@ -1,5 +1,55 @@ -import { spawnSync } from 'child_process'; +import { execFileSync, spawnSync } from 'child_process'; import * as fs from 'fs'; +import * as path from 'path'; +import * as osn from '../osn'; + +function probeMedia(mediaFile: string, args: string[]): any { + const executable = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'; + const ffprobe = [ + process.env.FFPROBE_PATH, + path.join(path.normalize(osn.wd), executable), + path.join(__dirname, '..', '..', '..', 'build', 'libobs-src', 'bin', + process.arch === 'x64' ? '64bit' : '32bit', executable), + ].find(candidate => candidate && fs.existsSync(candidate)) || executable; + + return JSON.parse(execFileSync(ffprobe, ['-v', 'error', ...args, '-of', 'json', mediaFile], { + encoding: 'utf8', + timeout: 30000, + })); +} + +export function getAudioStreamTitles(mediaFile: string): string[] { + const probe = probeMedia(mediaFile, ['-select_streams', 'a', '-show_entries', 'stream_tags=title']); + return (probe.streams || []).map((stream: { tags?: { title?: string } }) => stream.tags?.title || ''); +} + +export function getAudioStreamBitrates(mediaFile: string): { codec: string, bitrate: number }[] { + const probe = probeMedia(mediaFile, ['-select_streams', 'a', '-show_entries', 'stream=codec_name,bit_rate']); + return (probe.streams || []).map((stream: { codec_name: string, bit_rate: string }) => ({ + codec: stream.codec_name, + bitrate: Number(stream.bit_rate), + })); +} + +export function getVideoKeyframes(mediaFile: string): { + frameCount: number, + frameRate: number, + duration: number, + times: number[], +} { + const probe = probeMedia(mediaFile, [ + '-select_streams', 'v:0', '-skip_frame', 'nokey', '-show_entries', + 'frame=pts_time:stream=nb_frames,r_frame_rate,duration', + ]); + const stream = probe.streams[0]; + const [numerator, denominator] = stream.r_frame_rate.split('/').map(Number); + return { + frameCount: Number(stream.nb_frames), + frameRate: numerator / denominator, + duration: Number(stream.duration), + times: probe.frames.map((frame: { pts_time: string }) => Number(frame.pts_time)), + }; +} // Resolves an ffmpeg executable. Honours FFMPEG_PATH (point it at OBS's bundled // ffmpeg when ffmpeg is not on PATH), otherwise relies on `ffmpeg` from PATH. From 4ac5442debba292053860f9a2099a9ab2f17eef8 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Wed, 9 Sep 2026 09:34:51 +1200 Subject: [PATCH 2/8] Fix CodeQL filesystem race warnings in encoder settings tests --- .../src/test_osn_encoder_settings.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/osn-tests/src/test_osn_encoder_settings.ts b/tests/osn-tests/src/test_osn_encoder_settings.ts index b64fee727..4d5696dd3 100644 --- a/tests/osn-tests/src/test_osn_encoder_settings.ts +++ b/tests/osn-tests/src/test_osn_encoder_settings.ts @@ -167,7 +167,12 @@ describe(testName, function () { const filePath = path.join(configPath, 'recordEncoder.json'); const backupPath = `${filePath}.bak`; const original = fs.readFileSync(filePath); - const originalBackup = fs.existsSync(backupPath) ? fs.readFileSync(backupPath) : undefined; + let originalBackup: Buffer | undefined; + try { + originalBackup = fs.readFileSync(backupPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } const backup = JSON.stringify({ keyint_sec: 3, custom_boolean: false, custom_integer: 0, custom_number: 2.5, custom_string: '', custom_object: { enabled: false }, @@ -192,12 +197,18 @@ describe(testName, function () { fs.unlinkSync(backupPath); const defaults = osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Advanced'); expect(defaults).to.include({ keyint_sec: 0, preset: 'veryfast', crf: 23 }); - expect(fs.existsSync(filePath)).to.equal(false); - expect(fs.existsSync(backupPath)).to.equal(false); + expect(() => fs.readFileSync(filePath)).to.throw(Error).with.property('code', 'ENOENT'); + expect(() => fs.readFileSync(backupPath)).to.throw(Error).with.property('code', 'ENOENT'); } finally { fs.writeFileSync(filePath, original); if (originalBackup) fs.writeFileSync(backupPath, originalBackup); - else if (fs.existsSync(backupPath)) fs.unlinkSync(backupPath); + else { + try { + fs.unlinkSync(backupPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } } }); From 3b2cbdf8a215cf9bdc8d66da43c72c3cc100c682 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Wed, 9 Sep 2026 09:41:52 +1200 Subject: [PATCH 3/8] Regenerate declarations for encoder settings API --- js/module.d.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/module.d.ts b/js/module.d.ts index d3d8453dd..af55fa231 100644 --- a/js/module.d.ts +++ b/js/module.d.ts @@ -1148,10 +1148,11 @@ interface IAutoOptimizerEventProbe { interface IAutoOptimizer { run(request: IAutoOptimizerRequest, onProgress: (event: IAutoOptimizerEvent) => void): IAutoOptimizerRun; } -interface INodeObs { +export interface INodeObs { [key: string]: any; readonly AutoOptimizer: IAutoOptimizer; OBS_API_initAPI(options: IOBSAPIInitializationOptions): EVideoCodes; + OBS_settings_getEncoderSettings(encoderId: string, outputType: 'streaming' | 'recording', mode: 'Simple' | 'Advanced'): ISettings; } export declare const enum VCamOutputType { Invalid = 0, From 98968b662b5b242e9fb666794998a4146fecdc53 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Wed, 9 Sep 2026 10:37:58 +1200 Subject: [PATCH 4/8] Preserve native defaults when applying saved encoder settings --- js/module.d.ts | 1 + js/module.ts | 21 ++- obs-studio-client/source/video-encoder.cpp | 8 +- obs-studio-server/CMakeLists.txt | 1 + obs-studio-server/source/nodeobs_settings.cpp | 5 +- .../source/osn-video-encoder.cpp | 23 +++- .../tests/test-osn-encoder-settings.cpp | 125 ++++++++++++++++++ .../src/test_osn_encoder_settings.ts | 52 +++++++- 8 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 obs-studio-server/tests/test-osn-encoder-settings.cpp diff --git a/js/module.d.ts b/js/module.d.ts index af55fa231..55fe9e987 100644 --- a/js/module.d.ts +++ b/js/module.d.ts @@ -791,6 +791,7 @@ export interface IVideoEncoder extends IConfigurable, IReleasable { readonly active: boolean; readonly id: string; readonly lastError: string; + update(settings: ISettings, replace?: boolean): void; } export interface IAudioEncoder extends IReleasable { name: string; diff --git a/js/module.ts b/js/module.ts index c8abc0cdd..470408f8e 100644 --- a/js/module.ts +++ b/js/module.ts @@ -1768,6 +1768,19 @@ export interface IVideoEncoder extends IConfigurable, IReleasable { readonly active: boolean, readonly id: string, readonly lastError: string + + /** + * Updates explicit encoder settings. Requires an initialized IPC connection. + * Native defaults remain defaults, including adjustments made by the encoder during initialization. + * Replacement retains the encoder object and its references held by outputs. + * Validation failures leave the existing settings unchanged. + * @param settings - User settings to apply; omitted properties retain their current values unless replace is true + * @param replace - When true, removes previous user settings before applying these settings; only allowed while inactive + * @returns No value + * @throws {TypeError} If settings is not an object or replace is not a boolean + * @throws {Error} If the encoder reference is invalid, replacement is requested while active, or the IPC update fails + */ + update(settings: ISettings, replace?: boolean): void; } export interface IAudioEncoder extends IReleasable { @@ -2442,10 +2455,11 @@ export interface INodeObs { OBS_API_initAPI(options: IOBSAPIInitializationOptions): EVideoCodes; /** - * Reads the saved video encoder settings for Factory encoder creation, including encoder defaults. + * Reads the saved video encoder settings for Factory encoder creation. Defaults are not included: + * Factory creation applies them natively so encoders can adjust them during initialization. * Advanced mode includes all saved encoder properties and uses the backup configuration when needed. * Simple streaming includes its bitrate, enabled advanced options, and encoder preset. Standalone simple - * recording returns encoder defaults; the recording output applies its quality preset when it starts. + * recording returns an empty object; the recording output applies its quality preset when it starts. * A recording configured to use the stream encoder reads the streaming settings instead. * Service restrictions remain the responsibility of the output when it starts. * Advanced selections must use registered OBS IDs after the normal settings migration; this read only @@ -2453,12 +2467,13 @@ export interface INodeObs { * * This read does not create encoders or outputs, modify configuration files, or change running encoders. * The returned object is an independent copy with no native lifetime; modifying it does not save settings. - * Missing primary and backup encoder files use encoder defaults. Existing unreadable files cause an error + * Missing primary and backup encoder files return an empty object. Existing unreadable files cause an error * when neither the primary file nor its backup can be loaded. * @param encoderId - Registered OBS video encoder ID matching the saved selection after simple alias or legacy encoder conversion * @param outputType - Output whose saved video encoder settings to read * @param mode - Saved output mode, which must match the current configuration * @returns Settings ready to pass explicitly to VideoEncoderFactory.create + * or to an inactive encoder's update(settings, true) to replace its previous user settings * @throws {TypeError} If arguments are not exactly three strings, the ID is empty, or outputType or mode is unsupported * @throws {Error} If OBS is not initialized, the encoder is unavailable or does not match the saved selection, * the mode does not match the saved configuration, existing encoder files cannot be read, or IPC fails diff --git a/obs-studio-client/source/video-encoder.cpp b/obs-studio-client/source/video-encoder.cpp index 8a00fe627..a405b824a 100644 --- a/obs-studio-client/source/video-encoder.cpp +++ b/obs-studio-client/source/video-encoder.cpp @@ -239,6 +239,12 @@ void osn::VideoEncoder::Release(const Napi::CallbackInfo &info) void osn::VideoEncoder::Update(const Napi::CallbackInfo &info) { + if (info.Length() < 1 || !info[0].IsObject() || info[0].IsArray() || info[0].IsNull() || + (info.Length() > 1 && !info[1].IsUndefined() && !info[1].IsBoolean())) { + Napi::TypeError::New(info.Env(), "VideoEncoder.update expects a settings object and an optional replace boolean").ThrowAsJavaScriptException(); + return; + } + const bool replace = info.Length() > 1 && info[1].IsBoolean() && info[1].As().Value(); Napi::Object jsonObj = info[0].ToObject(); Napi::Object json = info.Env().Global().Get("JSON").As(); Napi::Function stringify = json.Get("stringify").As(); @@ -249,7 +255,7 @@ void osn::VideoEncoder::Update(const Napi::CallbackInfo &info) if (!conn) return; - auto response = conn->call_synchronous_helper("VideoEncoder", "Update", {ipc::value(this->uid), ipc::value(jsondata)}); + auto response = conn->call_synchronous_helper("VideoEncoder", "Update", {ipc::value(this->uid), ipc::value(jsondata), ipc::value(uint32_t(replace))}); ValidateResponse(info, response); } diff --git a/obs-studio-server/CMakeLists.txt b/obs-studio-server/CMakeLists.txt index d816b18ea..afca40ef2 100644 --- a/obs-studio-server/CMakeLists.txt +++ b/obs-studio-server/CMakeLists.txt @@ -625,6 +625,7 @@ if(BUILD_TESTING) add_executable( obs_studio_server_unit_tests "tests/test-osn-source.cpp" + "tests/test-osn-encoder-settings.cpp" "tests/test-osn-file-output.cpp" "tests/test-osn-multitrack-video-output.cpp" "tests/test-osn-scene-relative-coordinates.cpp" diff --git a/obs-studio-server/source/nodeobs_settings.cpp b/obs-studio-server/source/nodeobs_settings.cpp index e4bef4386..2d569a0e6 100644 --- a/obs-studio-server/source/nodeobs_settings.cpp +++ b/obs-studio-server/source/nodeobs_settings.cpp @@ -142,7 +142,7 @@ void OBS_settings::OBS_settings_getEncoderSettings(void *data, const int64_t id, PRETTY_ERROR_RETURN(ErrorCode::Error, "Requested encoder does not match the saved output encoder."); } - OBSDataAutoRelease settings = obs_encoder_defaults(encoderId.c_str()); + OBSDataAutoRelease settings = obs_data_create(); if (simple) { // Simple recording quality and service restrictions are applied by the output when it starts. if (!recording) { @@ -165,7 +165,8 @@ void OBS_settings::OBS_settings_getEncoderSettings(void *data, const int64_t id, } rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); - rval.push_back(ipc::value(obs_data_get_json_with_defaults(settings))); + // Keep defaults native so encoders can adjust them during initialization. + rval.push_back(ipc::value(obs_data_get_json(settings))); AUTO_DEBUG; } diff --git a/obs-studio-server/source/osn-video-encoder.cpp b/obs-studio-server/source/osn-video-encoder.cpp index 871f88b77..173776225 100644 --- a/obs-studio-server/source/osn-video-encoder.cpp +++ b/obs-studio-server/source/osn-video-encoder.cpp @@ -20,6 +20,7 @@ #include "osn-error.hpp" #include "shared.hpp" #include "osn-encoders.hpp" +#include void osn::VideoEncoder::Register(ipc::server &srv) { @@ -35,7 +36,8 @@ void osn::VideoEncoder::Register(ipc::server &srv) cls->register_function(std::make_shared("GetLastError", std::vector{ipc::type::UInt64}, GetLastError)); cls->register_function(std::make_shared("Release", std::vector{ipc::type::UInt64}, Release)); cls->register_function(std::make_shared("Finalize", std::vector{ipc::type::UInt64}, Finalize)); - cls->register_function(std::make_shared("Update", std::vector{ipc::type::UInt64, ipc::type::String}, Update)); + cls->register_function( + std::make_shared("Update", std::vector{ipc::type::UInt64, ipc::type::String, ipc::type::UInt32}, Update)); cls->register_function(std::make_shared("GetProperties", std::vector{ipc::type::UInt64}, GetProperties)); cls->register_function(std::make_shared("GetSettings", std::vector{ipc::type::UInt64}, GetSettings)); srv.register_collection(cls); @@ -202,9 +204,24 @@ void osn::VideoEncoder::Update(void *data, const int64_t id, const std::vector + +#include +#include +#include + +#include "nodeobs_configManager.hpp" +#include "nodeobs_settings.h" +#include "obs-setup.hpp" +#include "osn-error.hpp" +#include "osn-video-encoder.hpp" + +namespace { + +class ScopedConfigValue { +public: + ScopedConfigValue(config_t *config, const char *section, const char *name, const char *value) + : config(config), section(section), name(name), hadUserValue(config_has_user_value(config, section, name)) + { + const char *previous = config_get_string(config, section, name); + previousValue = previous ? previous : ""; + config_set_string(config, section, name, value); + } + + ~ScopedConfigValue() + { + if (hadUserValue) + config_set_string(config, section, name, previousValue.c_str()); + else + config_remove_value(config, section, name); + } + +private: + config_t *config; + const char *section; + const char *name; + bool hadUserValue; + std::string previousValue; +}; + +class ScopedEncoder { +public: + explicit ScopedEncoder(obs_encoder_t *encoder) : encoder(encoder) {} + ~ScopedEncoder() + { + osn::VideoEncoder::Manager::GetInstance().free(encoder); + obs_encoder_release(encoder); + } + +private: + obs_encoder_t *encoder; +}; + +std::string readSimpleStreamingSettings() +{ + std::vector args = {ipc::value("obs_x264"), ipc::value("streaming"), ipc::value("Simple")}; + std::vector response; + OBS_settings::OBS_settings_getEncoderSettings(nullptr, 0, args, response); + REQUIRE(response.size() == 2); + REQUIRE((ErrorCode)response[0].value_union.ui64 == ErrorCode::Ok); + return response[1].value_str; +} + +} // namespace + +TEST_CASE("Encoder settings preserve native defaults during creation and replacement", "[encoder-settings]") +{ + osn::tests::ObsSetup setupOBS; + config_t *config = ConfigManager::getInstance().getBasic(); + ScopedConfigValue mode(config, "Output", "Mode", "Simple"); + ScopedConfigValue selectedEncoder(config, "SimpleOutput", "StreamEncoder", "x264"); + ScopedConfigValue advanced(config, "SimpleOutput", "UseAdvanced", "false"); + ScopedConfigValue preset(config, "SimpleOutput", "Preset", "fast"); + ScopedConfigValue customSettings(config, "SimpleOutput", "x264Settings", "scenecut=0"); + + for (bool explicitPreset : {false, true}) { + INFO("UseAdvanced = " << explicitPreset); + config_set_bool(config, "SimpleOutput", "UseAdvanced", explicitPreset); + + const std::string settingsJson = readSimpleStreamingSettings(); + OBSDataAutoRelease snapshot = obs_data_create_from_json(settingsJson.c_str()); + REQUIRE(snapshot); + CHECK(obs_data_has_user_value(snapshot, "preset") == explicitPreset); + CHECK_FALSE(obs_data_has_user_value(snapshot, "keyint_sec")); + + std::vector createArgs = {ipc::value("obs_x264"), ipc::value("native-defaults-test"), ipc::value(settingsJson)}; + std::vector response; + osn::VideoEncoder::Create(nullptr, 0, createArgs, response); + REQUIRE(response.size() == 2); + REQUIRE((ErrorCode)response[0].value_union.ui64 == ErrorCode::Ok); + const uint64_t encoderId = response[1].value_union.ui64; + obs_encoder_t *encoder = osn::VideoEncoder::Manager::GetInstance().find(encoderId); + ScopedEncoder encoderCleanup(encoder); + REQUIRE(encoder); + OBSDataAutoRelease settings = obs_encoder_get_settings(encoder); + REQUIRE(settings); + + // Encoders can refine defaults after detecting hardware capabilities. A saved + // override must win, while a default serialized by OSN must not pin the value. + obs_data_set_default_string(settings, "preset", "medium"); + CHECK(obs_data_has_user_value(settings, "preset") == explicitPreset); + CHECK(std::string(obs_data_get_string(settings, "preset")) == (explicitPreset ? "fast" : "medium")); + + std::vector updateArgs = {ipc::value(encoderId), ipc::value("{\"keyint_sec\":2}"), ipc::value(uint32_t{0})}; + response.clear(); + osn::VideoEncoder::Update(nullptr, 0, updateArgs, response); + REQUIRE(response.size() == 1); + REQUIRE((ErrorCode)response[0].value_union.ui64 == ErrorCode::Ok); + CHECK(obs_data_has_user_value(settings, "keyint_sec")); + CHECK(obs_data_get_int(settings, "keyint_sec") == 2); + CHECK(std::string(obs_data_get_string(settings, "preset")) == (explicitPreset ? "fast" : "medium")); + + config_set_bool(config, "SimpleOutput", "UseAdvanced", false); + updateArgs = {ipc::value(encoderId), ipc::value(readSimpleStreamingSettings()), ipc::value(uint32_t{1})}; + response.clear(); + osn::VideoEncoder::Update(nullptr, 0, updateArgs, response); + REQUIRE(response.size() == 1); + REQUIRE((ErrorCode)response[0].value_union.ui64 == ErrorCode::Ok); + CHECK_FALSE(obs_data_has_user_value(settings, "preset")); + CHECK_FALSE(obs_data_has_user_value(settings, "x264opts")); + CHECK_FALSE(obs_data_has_user_value(settings, "keyint_sec")); + CHECK(std::string(obs_data_get_string(settings, "preset")) == "medium"); + CHECK(obs_data_get_int(settings, "keyint_sec") == 0); + } +} diff --git a/tests/osn-tests/src/test_osn_encoder_settings.ts b/tests/osn-tests/src/test_osn_encoder_settings.ts index 4d5696dd3..ed35f142f 100644 --- a/tests/osn-tests/src/test_osn_encoder_settings.ts +++ b/tests/osn-tests/src/test_osn_encoder_settings.ts @@ -163,7 +163,7 @@ describe(testName, function () { .to.deep.equal(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'streaming', 'Advanced')); }); - it('Reads the complete backup without changing files and restores defaults when settings are absent', function () { + it('Reads the complete backup without changing files or making defaults explicit', function () { const filePath = path.join(configPath, 'recordEncoder.json'); const backupPath = `${filePath}.bak`; const original = fs.readFileSync(filePath); @@ -182,8 +182,14 @@ describe(testName, function () { fs.writeFileSync(filePath, '{invalid json'); fs.writeFileSync(backupPath, backup); const settings = osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Advanced'); - expect(settings).to.deep.include(JSON.parse(backup)); - expect(settings).to.have.property('preset', 'veryfast'); + expect(settings).to.deep.equal(JSON.parse(backup)); + const encoder = osn.VideoEncoderFactory.create('obs_x264', 'backup-settings', settings); + try { + expect(encoder.settings).to.deep.equal(JSON.parse(backup)); + expect(encoder.properties.get('preset').value).to.equal('veryfast'); + } finally { + encoder.release(); + } expect(fs.readFileSync(filePath, 'utf8')).to.equal('{invalid json'); expect(fs.readFileSync(backupPath, 'utf8')).to.equal(backup); @@ -195,8 +201,8 @@ describe(testName, function () { fs.unlinkSync(filePath); fs.unlinkSync(backupPath); - const defaults = osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Advanced'); - expect(defaults).to.include({ keyint_sec: 0, preset: 'veryfast', crf: 23 }); + const emptySettings = osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Advanced'); + expect(emptySettings).to.deep.equal({}); expect(() => fs.readFileSync(filePath)).to.throw(Error).with.property('code', 'ENOENT'); expect(() => fs.readFileSync(backupPath)).to.throw(Error).with.property('code', 'ENOENT'); } finally { @@ -212,6 +218,33 @@ describe(testName, function () { } }); + it('Replaces inactive user settings without changing defaults or normal update behavior', function () { + const encoder = osn.VideoEncoderFactory.create('obs_x264', 'replace-settings', { + preset: 'fast', keyint_sec: 1, x264opts: 'scenecut=0', + }); + try { + encoder.update({ bitrate: 3100 }); + const saved = encoder.settings; + expect(saved).to.deep.equal({ bitrate: 3100, preset: 'fast', keyint_sec: 1, x264opts: 'scenecut=0' }); + for (const invalid of [null, [], 'invalid']) { + expect(() => encoder.update(invalid as any, true)).to.throw(TypeError); + } + expect(() => encoder.update({}, 'true' as any)).to.throw(TypeError); + expect(() => encoder.update({ toJSON: () => [] }, true)).to.throw(Error); + expect(encoder.settings).to.deep.equal(saved); + + encoder.update({ bitrate: 3200 }, true); + expect(encoder.settings).to.deep.equal({ bitrate: 3200 }); + expect(encoder.properties.get('preset').value).to.equal('veryfast'); + expect(encoder.properties.get('keyint_sec').value).to.equal(0); + encoder.update({}, true); + expect(encoder.settings).to.deep.equal({}); + expect(encoder.properties.get('bitrate').value).to.equal(4500); + } finally { + encoder.release(); + } + }); + it('Maps simple streaming settings and leaves standalone recording quality to the output', function () { obs.setSetting(outputCategory, 'Mode', 'Simple'); obs.setSetting(outputCategory, 'StreamEncoder', 'x264'); @@ -227,11 +260,11 @@ describe(testName, function () { obs.setSetting(outputCategory, 'RecQuality', 'HQ'); obs.setSetting(outputCategory, 'RecEncoder', 'x264'); expect(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Simple')) - .to.include({ keyint_sec: 0, preset: 'veryfast', crf: 23 }); + .to.deep.equal({}); obs.setSetting(outputCategory, 'UseAdvanced', false); expect(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'streaming', 'Simple')) - .to.include({ bitrate: 3100, preset: 'veryfast', x264opts: '' }); + .to.deep.equal({ bitrate: 3100, rate_control: 'CBR' }); }); it('Keeps the configured AMD preset when simple streaming encoders start', async function () { @@ -332,6 +365,11 @@ describe(testName, function () { const started = await obs.getNextSignalInfo(EOBSOutputType.Recording, EOBSOutputSignal.Start); expect(started.signal, started.error).to.equal(EOBSOutputSignal.Start); expect(started.code, started.error).to.equal(0); + if (outputType === 'recording' && keyint === 1) { + const activeSettings = encoder.settings; + expect(() => encoder.update({ keyint_sec: 8 }, true)).to.throw(Error, /active/); + expect(encoder.settings).to.deep.equal(activeSettings); + } await sleep(6200); recording.stop(); const stopped = await obs.getNextSignalInfo(EOBSOutputType.Recording, EOBSOutputSignal.Stop); From fe549560d95a76168661cd350911668924c5cce2 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Wed, 9 Sep 2026 11:09:01 +1200 Subject: [PATCH 5/8] Extract reusable scoped helpers for server tests --- obs-studio-server/CMakeLists.txt | 1 + obs-studio-server/tests/scoped-helpers.hpp | 61 +++++++++++++++++++ .../tests/test-osn-encoder-settings.cpp | 52 +++------------- 3 files changed, 69 insertions(+), 45 deletions(-) create mode 100644 obs-studio-server/tests/scoped-helpers.hpp diff --git a/obs-studio-server/CMakeLists.txt b/obs-studio-server/CMakeLists.txt index afca40ef2..557f49177 100644 --- a/obs-studio-server/CMakeLists.txt +++ b/obs-studio-server/CMakeLists.txt @@ -632,6 +632,7 @@ if(BUILD_TESTING) "tests/test-osn-video-mix-lifecycle.cpp" "tests/obs-setup.cpp" "tests/obs-setup.hpp" + "tests/scoped-helpers.hpp" ) file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" OSN_SOURCE_DIR_PATH) diff --git a/obs-studio-server/tests/scoped-helpers.hpp b/obs-studio-server/tests/scoped-helpers.hpp new file mode 100644 index 000000000..300da8f6e --- /dev/null +++ b/obs-studio-server/tests/scoped-helpers.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +#include "osn-video-encoder.hpp" + +namespace osn::tests { + +// Restores the previous in-memory value, including the absence of a user override. +// The configuration must outlive this guard; no configuration files are saved. +class ScopedConfigValue { +public: + ScopedConfigValue(config_t *config, const char *section, const char *name, const char *value) + : config(config), section(section), name(name), hadUserValue(config_has_user_value(config, section, name)) + { + const char *previous = config_get_string(config, section, name); + previousValue = previous ? previous : ""; + config_set_string(config, section, name, value); + } + + ScopedConfigValue(const ScopedConfigValue &) = delete; + ScopedConfigValue &operator=(const ScopedConfigValue &) = delete; + + ~ScopedConfigValue() + { + if (hadUserValue) + config_set_string(config, section.c_str(), name.c_str(), previousValue.c_str()); + else + config_remove_value(config, section.c_str(), name.c_str()); + } + +private: + config_t *config; + std::string section; + std::string name; + bool hadUserValue; + std::string previousValue; +}; + +// Owns one existing native reference and its VideoEncoder::Manager registration. +// Neither may be released elsewhere while this guard is alive. +class ScopedEncoder { +public: + explicit ScopedEncoder(obs_encoder_t *encoder) : encoder(encoder) {} + + ScopedEncoder(const ScopedEncoder &) = delete; + ScopedEncoder &operator=(const ScopedEncoder &) = delete; + + ~ScopedEncoder() + { + osn::VideoEncoder::Manager::GetInstance().free(encoder); + obs_encoder_release(encoder); + } + +private: + obs_encoder_t *encoder; +}; + +} // namespace osn::tests diff --git a/obs-studio-server/tests/test-osn-encoder-settings.cpp b/obs-studio-server/tests/test-osn-encoder-settings.cpp index 4bd0ba4bf..0b0a6851c 100644 --- a/obs-studio-server/tests/test-osn-encoder-settings.cpp +++ b/obs-studio-server/tests/test-osn-encoder-settings.cpp @@ -9,48 +9,10 @@ #include "obs-setup.hpp" #include "osn-error.hpp" #include "osn-video-encoder.hpp" +#include "scoped-helpers.hpp" namespace { -class ScopedConfigValue { -public: - ScopedConfigValue(config_t *config, const char *section, const char *name, const char *value) - : config(config), section(section), name(name), hadUserValue(config_has_user_value(config, section, name)) - { - const char *previous = config_get_string(config, section, name); - previousValue = previous ? previous : ""; - config_set_string(config, section, name, value); - } - - ~ScopedConfigValue() - { - if (hadUserValue) - config_set_string(config, section, name, previousValue.c_str()); - else - config_remove_value(config, section, name); - } - -private: - config_t *config; - const char *section; - const char *name; - bool hadUserValue; - std::string previousValue; -}; - -class ScopedEncoder { -public: - explicit ScopedEncoder(obs_encoder_t *encoder) : encoder(encoder) {} - ~ScopedEncoder() - { - osn::VideoEncoder::Manager::GetInstance().free(encoder); - obs_encoder_release(encoder); - } - -private: - obs_encoder_t *encoder; -}; - std::string readSimpleStreamingSettings() { std::vector args = {ipc::value("obs_x264"), ipc::value("streaming"), ipc::value("Simple")}; @@ -67,11 +29,11 @@ TEST_CASE("Encoder settings preserve native defaults during creation and replace { osn::tests::ObsSetup setupOBS; config_t *config = ConfigManager::getInstance().getBasic(); - ScopedConfigValue mode(config, "Output", "Mode", "Simple"); - ScopedConfigValue selectedEncoder(config, "SimpleOutput", "StreamEncoder", "x264"); - ScopedConfigValue advanced(config, "SimpleOutput", "UseAdvanced", "false"); - ScopedConfigValue preset(config, "SimpleOutput", "Preset", "fast"); - ScopedConfigValue customSettings(config, "SimpleOutput", "x264Settings", "scenecut=0"); + osn::tests::ScopedConfigValue mode(config, "Output", "Mode", "Simple"); + osn::tests::ScopedConfigValue selectedEncoder(config, "SimpleOutput", "StreamEncoder", "x264"); + osn::tests::ScopedConfigValue advanced(config, "SimpleOutput", "UseAdvanced", "false"); + osn::tests::ScopedConfigValue preset(config, "SimpleOutput", "Preset", "fast"); + osn::tests::ScopedConfigValue customSettings(config, "SimpleOutput", "x264Settings", "scenecut=0"); for (bool explicitPreset : {false, true}) { INFO("UseAdvanced = " << explicitPreset); @@ -90,7 +52,7 @@ TEST_CASE("Encoder settings preserve native defaults during creation and replace REQUIRE((ErrorCode)response[0].value_union.ui64 == ErrorCode::Ok); const uint64_t encoderId = response[1].value_union.ui64; obs_encoder_t *encoder = osn::VideoEncoder::Manager::GetInstance().find(encoderId); - ScopedEncoder encoderCleanup(encoder); + osn::tests::ScopedEncoder encoderCleanup(encoder); REQUIRE(encoder); OBSDataAutoRelease settings = obs_encoder_get_settings(encoder); REQUIRE(settings); From d0b7b70255f259ebe4c56a4f5b7373b4b9c43cda Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Wed, 9 Sep 2026 13:57:44 +1200 Subject: [PATCH 6/8] API cleanup --- js/module.d.ts | 3 +-- js/module.ts | 28 +--------------------------- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/js/module.d.ts b/js/module.d.ts index 55fe9e987..45d5d76b8 100644 --- a/js/module.d.ts +++ b/js/module.d.ts @@ -1149,11 +1149,10 @@ interface IAutoOptimizerEventProbe { interface IAutoOptimizer { run(request: IAutoOptimizerRequest, onProgress: (event: IAutoOptimizerEvent) => void): IAutoOptimizerRun; } -export interface INodeObs { +interface INodeObs { [key: string]: any; readonly AutoOptimizer: IAutoOptimizer; OBS_API_initAPI(options: IOBSAPIInitializationOptions): EVideoCodes; - OBS_settings_getEncoderSettings(encoderId: string, outputType: 'streaming' | 'recording', mode: 'Simple' | 'Advanced'): ISettings; } export declare const enum VCamOutputType { Invalid = 0, diff --git a/js/module.ts b/js/module.ts index 470408f8e..a6bea7120 100644 --- a/js/module.ts +++ b/js/module.ts @@ -2439,7 +2439,7 @@ interface IAutoOptimizer { /** * Typed methods on the add-on's otherwise dynamic export. */ -export interface INodeObs { +interface INodeObs { [key: string]: any; /** Starts and manages Auto Optimizer runs. */ @@ -2453,32 +2453,6 @@ export interface INodeObs { * @throws {Error} If the IPC call fails or OSN returns an error response without an initialization result */ OBS_API_initAPI(options: IOBSAPIInitializationOptions): EVideoCodes; - - /** - * Reads the saved video encoder settings for Factory encoder creation. Defaults are not included: - * Factory creation applies them natively so encoders can adjust them during initialization. - * Advanced mode includes all saved encoder properties and uses the backup configuration when needed. - * Simple streaming includes its bitrate, enabled advanced options, and encoder preset. Standalone simple - * recording returns an empty object; the recording output applies its quality preset when it starts. - * A recording configured to use the stream encoder reads the streaming settings instead. - * Service restrictions remain the responsibility of the output when it starts. - * Advanced selections must use registered OBS IDs after the normal settings migration; this read only - * resolves simple encoder aliases and the existing JIM encoder migration. - * - * This read does not create encoders or outputs, modify configuration files, or change running encoders. - * The returned object is an independent copy with no native lifetime; modifying it does not save settings. - * Missing primary and backup encoder files return an empty object. Existing unreadable files cause an error - * when neither the primary file nor its backup can be loaded. - * @param encoderId - Registered OBS video encoder ID matching the saved selection after simple alias or legacy encoder conversion - * @param outputType - Output whose saved video encoder settings to read - * @param mode - Saved output mode, which must match the current configuration - * @returns Settings ready to pass explicitly to VideoEncoderFactory.create - * or to an inactive encoder's update(settings, true) to replace its previous user settings - * @throws {TypeError} If arguments are not exactly three strings, the ID is empty, or outputType or mode is unsupported - * @throws {Error} If OBS is not initialized, the encoder is unavailable or does not match the saved selection, - * the mode does not match the saved configuration, existing encoder files cannot be read, or IPC fails - */ - OBS_settings_getEncoderSettings(encoderId: string, outputType: 'streaming' | 'recording', mode: 'Simple' | 'Advanced'): ISettings; } export const enum VCamOutputType { From 922662d2ec03cb8605666e88c1a693bb89edba51 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Wed, 9 Sep 2026 21:14:26 +1200 Subject: [PATCH 7/8] Fix platform-specific x264 IDs in encoder settings tests --- obs-studio-server/tests/test-osn-encoder-settings.cpp | 3 ++- tests/osn-tests/src/test_osn_encoder_settings.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/obs-studio-server/tests/test-osn-encoder-settings.cpp b/obs-studio-server/tests/test-osn-encoder-settings.cpp index 0b0a6851c..9732c8e00 100644 --- a/obs-studio-server/tests/test-osn-encoder-settings.cpp +++ b/obs-studio-server/tests/test-osn-encoder-settings.cpp @@ -7,6 +7,7 @@ #include "nodeobs_configManager.hpp" #include "nodeobs_settings.h" #include "obs-setup.hpp" +#include "osn-encoders.hpp" #include "osn-error.hpp" #include "osn-video-encoder.hpp" #include "scoped-helpers.hpp" @@ -30,7 +31,7 @@ TEST_CASE("Encoder settings preserve native defaults during creation and replace osn::tests::ObsSetup setupOBS; config_t *config = ConfigManager::getInstance().getBasic(); osn::tests::ScopedConfigValue mode(config, "Output", "Mode", "Simple"); - osn::tests::ScopedConfigValue selectedEncoder(config, "SimpleOutput", "StreamEncoder", "x264"); + osn::tests::ScopedConfigValue selectedEncoder(config, "SimpleOutput", "StreamEncoder", SIMPLE_ENCODER_X264); osn::tests::ScopedConfigValue advanced(config, "SimpleOutput", "UseAdvanced", "false"); osn::tests::ScopedConfigValue preset(config, "SimpleOutput", "Preset", "fast"); osn::tests::ScopedConfigValue customSettings(config, "SimpleOutput", "x264Settings", "scenecut=0"); diff --git a/tests/osn-tests/src/test_osn_encoder_settings.ts b/tests/osn-tests/src/test_osn_encoder_settings.ts index ed35f142f..dd5c6ed4f 100644 --- a/tests/osn-tests/src/test_osn_encoder_settings.ts +++ b/tests/osn-tests/src/test_osn_encoder_settings.ts @@ -246,8 +246,9 @@ describe(testName, function () { }); it('Maps simple streaming settings and leaves standalone recording quality to the output', function () { + const simpleEncoder = obs.os === 'win32' ? 'x264' : 'obs_x264'; obs.setSetting(outputCategory, 'Mode', 'Simple'); - obs.setSetting(outputCategory, 'StreamEncoder', 'x264'); + obs.setSetting(outputCategory, 'StreamEncoder', simpleEncoder); obs.setSetting(outputCategory, 'UseAdvanced', true); saveSettings({ VBitrate: 3100, Preset: 'faster', x264Settings: 'scenecut=0' }); expect(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'streaming', 'Simple')) @@ -258,7 +259,7 @@ describe(testName, function () { .to.deep.equal(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'streaming', 'Simple')); obs.setSetting(outputCategory, 'RecQuality', 'HQ'); - obs.setSetting(outputCategory, 'RecEncoder', 'x264'); + obs.setSetting(outputCategory, 'RecEncoder', simpleEncoder); expect(osn.NodeObs.OBS_settings_getEncoderSettings('obs_x264', 'recording', 'Simple')) .to.deep.equal({}); From 798cd4818b4ec67267aa814d45b56771524dda69 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Thu, 10 Sep 2026 04:46:20 +1200 Subject: [PATCH 8/8] Find bundled ffprobe in macOS test packages --- tests/osn-tests/util/media_probe.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/osn-tests/util/media_probe.ts b/tests/osn-tests/util/media_probe.ts index 451f6386e..f71a8bb48 100644 --- a/tests/osn-tests/util/media_probe.ts +++ b/tests/osn-tests/util/media_probe.ts @@ -8,6 +8,7 @@ function probeMedia(mediaFile: string, args: string[]): any { const ffprobe = [ process.env.FFPROBE_PATH, path.join(path.normalize(osn.wd), executable), + path.join(path.normalize(osn.wd), 'Frameworks', executable), path.join(__dirname, '..', '..', '..', 'build', 'libobs-src', 'bin', process.arch === 'x64' ? '64bit' : '32bit', executable), ].find(candidate => candidate && fs.existsSync(candidate)) || executable;