diff --git a/obs-studio-server/source/nodeobs_service.cpp b/obs-studio-server/source/nodeobs_service.cpp index 31097139d..1047957b5 100644 --- a/obs-studio-server/source/nodeobs_service.cpp +++ b/obs-studio-server/source/nodeobs_service.cpp @@ -569,9 +569,11 @@ int OBS_service::doResetVideoContext(obs_video_info *ovi) base_canvas = obs_create_video_info(); int ret = obs_set_video_info(base_canvas, ovi); + osn::Video::InvalidateAppliedVideoInfo(); return ret; } catch (const char *error) { + osn::Video::InvalidateAppliedVideoInfo(); blog(LOG_ERROR, "Failed to reset video: %s", error); return OBS_VIDEO_FAIL; } diff --git a/obs-studio-server/source/nodeobs_settings.cpp b/obs-studio-server/source/nodeobs_settings.cpp index 76793f399..77adb0a19 100644 --- a/obs-studio-server/source/nodeobs_settings.cpp +++ b/obs-studio-server/source/nodeobs_settings.cpp @@ -3781,6 +3781,7 @@ bool OBS_settings::saveSettings(std::string nameCategory, std::vector #include +#include +#include +#include #include "osn-error.hpp" #include "shared.hpp" #include "osn-streaming.hpp" @@ -311,6 +314,56 @@ enum video_range_type osn::Video::ColoRangeFromStr(const std::string &value) return VIDEO_RANGE_DEFAULT; } +// Settings last applied successfully to each canvas through SetVideoContext. libobs rebuilds the +// canvas' whole video pipeline on every obs_set_video_info call (~130 ms), even when nothing +// changes, so identical re-applies are skipped. Keyed on the canvas pointer; entries are +// dropped when the canvas is removed. +static std::mutex lastAppliedVideoMutex; +static std::unordered_map lastAppliedVideo; + +static bool SameVideoInfo(const obs_video_info &a, const obs_video_info &b) +{ + const char *ga = a.graphics_module ? a.graphics_module : ""; + const char *gb = b.graphics_module ? b.graphics_module : ""; + return strcmp(ga, gb) == 0 && a.fps_num == b.fps_num && a.fps_den == b.fps_den && a.fps_type == b.fps_type && a.base_width == b.base_width && + a.base_height == b.base_height && a.output_width == b.output_width && a.output_height == b.output_height && a.output_format == b.output_format && + a.colorspace == b.colorspace && a.range == b.range && a.scale_type == b.scale_type && a.adapter == b.adapter && + a.gpu_conversion == b.gpu_conversion; +} + +static bool VideoInfoUnchanged(const obs_video_info *canvas, const obs_video_info &requested) +{ + std::lock_guard lock(lastAppliedVideoMutex); + const auto it = lastAppliedVideo.find(canvas); + return it != lastAppliedVideo.end() && SameVideoInfo(it->second, requested); +} + +static void RememberAppliedVideoInfo(const obs_video_info *canvas, const obs_video_info &applied) +{ + std::lock_guard lock(lastAppliedVideoMutex); + lastAppliedVideo[canvas] = applied; +} + +static void ForgetAppliedVideoInfo(const obs_video_info *canvas) +{ + std::lock_guard lock(lastAppliedVideoMutex); + lastAppliedVideo.erase(canvas); +} + +// libobs video state is process-wide, so a failed call can leave every canvas torn down. +void osn::Video::InvalidateAppliedVideoInfo() +{ + std::lock_guard lock(lastAppliedVideoMutex); + lastAppliedVideo.clear(); +} + +static void ApplyConfiguredVideoLevels() +{ + const float sdr_white_level = (float)config_get_uint(ConfigManager::getInstance().getBasic(), "Video", "SdrWhiteLevel"); + const float hdr_nominal_peak_level = (float)config_get_uint(ConfigManager::getInstance().getBasic(), "Video", "HdrNominalPeakLevel"); + obs_set_video_levels(sdr_white_level, hdr_nominal_peak_level); +} + void osn::Video::SetVideoContext(void *data, const int64_t id, const std::vector &args, std::vector &rval) { blog(LOG_INFO, "[VIDEO_CANVAS] Set video context called"); @@ -357,6 +410,14 @@ void osn::Video::SetVideoContext(void *data, const int64_t id, const std::vector video.gpu_conversion = true; video.fps_type = args[10].value_union.ui32; + if (VideoInfoUnchanged(canvas, video)) { + blog(LOG_INFO, "[VIDEO_CANVAS] Set video context skipped for 0x%" PRIXPTR ": settings unchanged", (uintptr_t)canvas); + ApplyConfiguredVideoLevels(); + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); + AUTO_DEBUG; + return; + } + // Updating a canvas has the same libobs restriction as removing it. Cancel // active Auto Optimizer work and release its temporary outputs first. if (!autoOptimizer::CancelActiveSession()) { @@ -377,12 +438,11 @@ void osn::Video::SetVideoContext(void *data, const int64_t id, const std::vector if (ret != OBS_VIDEO_SUCCESS) { blog(LOG_ERROR, "Failed to set video context"); + InvalidateAppliedVideoInfo(); rval.push_back(ipc::value((uint64_t)ErrorCode::Error)); } else { - const float sdr_white_level = (float)config_get_uint(ConfigManager::getInstance().getBasic(), "Video", "SdrWhiteLevel"); - const float hdr_nominal_peak_level = (float)config_get_uint(ConfigManager::getInstance().getBasic(), "Video", "HdrNominalPeakLevel"); - obs_set_video_levels(sdr_white_level, hdr_nominal_peak_level); - + RememberAppliedVideoInfo(canvas, video); + ApplyConfiguredVideoLevels(); rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); } @@ -442,13 +502,16 @@ void osn::Video::RemoveVideoContext(void *data, const int64_t id, const std::vec if (ret == OBS_VIDEO_INFO_IN_USE) { PRETTY_ERROR_RETURN(ErrorCode::Error, "Cannot remove video context while scene items are assigned to it."); } else if (ret == OBS_VIDEO_REINITIALIZATION_FAILED) { + InvalidateAppliedVideoInfo(); osn::Video::Manager::GetInstance().free(args[0].value_union.ui64); PRETTY_ERROR_RETURN(ErrorCode::InvalidReference, "Video context was removed, but the remaining video contexts failed to initialize."); } else if (ret == OBS_VIDEO_CURRENTLY_ACTIVE) { PRETTY_ERROR_RETURN(ErrorCode::Error, "Cannot remove video context while video is active."); } else if (ret != OBS_VIDEO_SUCCESS) { + InvalidateAppliedVideoInfo(); PRETTY_ERROR_RETURN(ErrorCode::Error, "Failed to remove video context."); } else { + ForgetAppliedVideoInfo(canvas); osn::Video::Manager::GetInstance().free(args[0].value_union.ui64); rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); } diff --git a/obs-studio-server/source/osn-video.hpp b/obs-studio-server/source/osn-video.hpp index bd47dd804..586c04f23 100644 --- a/obs-studio-server/source/osn-video.hpp +++ b/obs-studio-server/source/osn-video.hpp @@ -48,6 +48,7 @@ class Video { static void GetVideoContext(void *data, const int64_t id, const std::vector &args, std::vector &rval); static void SetVideoContext(void *data, const int64_t id, const std::vector &args, std::vector &rval); static void AddVideoContext(void *data, const int64_t id, const std::vector &args, std::vector &rval); + static void InvalidateAppliedVideoInfo(); static void RemoveVideoContext(void *data, const int64_t id, const std::vector &args, std::vector &rval); static void GetLegacySettings(void *data, const int64_t id, const std::vector &args, std::vector &rval); static void SetLegacySettings(void *data, const int64_t id, const std::vector &args, std::vector &rval); diff --git a/tests/osn-tests/src/test_osn_video.ts b/tests/osn-tests/src/test_osn_video.ts index 163c2625d..a2fd25109 100644 --- a/tests/osn-tests/src/test_osn_video.ts +++ b/tests/osn-tests/src/test_osn_video.ts @@ -1,5 +1,7 @@ import 'mocha'; import { expect } from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; import * as osn from '../osn'; import { logInfo, logEmptyLine } from '../util/logger'; import { OBSHandler } from '../util/obs_handler'; @@ -9,6 +11,15 @@ import { EFPSType } from '../osn'; const testName = 'osn-video'; +function countSkippedSetVideoCalls(): number { + const logDir = path.join(__dirname, '..', 'osnData', 'slobs-client', 'node-obs', 'logs'); + const newest = fs.readdirSync(logDir) + .map(name => ({ name, mtime: fs.statSync(path.join(logDir, name)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime)[0]; + const log = fs.readFileSync(path.join(logDir, newest.name), 'utf8'); + return log.split('Set video context skipped').length - 1; +} + describe(testName, () => { let obs: OBSHandler; let hasTestFailed: boolean = false; @@ -192,6 +203,72 @@ describe(testName, () => { context.destroy(); }); + it('Re-applying identical video settings is accepted and later changes still apply', () => { + const context = osn.VideoFactory.create(); + const videoInfo: osn.IVideoInfo = { + fpsNum: 60, + fpsDen: 1, + baseWidth: 1280, + baseHeight: 720, + outputWidth: 1280, + outputHeight: 720, + outputFormat: osn.EVideoFormat.NV12, + colorspace: osn.EColorSpace.CS709, + range: osn.ERangeType.Partial, + scaleType: osn.EScaleType.Bicubic, + fpsType: EFPSType.Fractional + }; + context.video = videoInfo; + const skippedBefore = countSkippedSetVideoCalls(); + // identical re-applies: must succeed, be skipped by the server, and read back unchanged + context.video = videoInfo; + context.video = { ...videoInfo }; + expect(countSkippedSetVideoCalls() - skippedBefore).to.equal(2, 'Identical video settings were not skipped'); + let current = context.video; + expect(current.baseWidth).to.equal(1280, GetErrorMessage(ETestErrorMsg.VideoSetBaseWidth)); + expect(current.baseHeight).to.equal(720, GetErrorMessage(ETestErrorMsg.VideoSetBaseHeight)); + expect(current.outputWidth).to.equal(1280, GetErrorMessage(ETestErrorMsg.VideoSetOutputWidth)); + expect(current.fpsNum).to.equal(60, GetErrorMessage(ETestErrorMsg.VideoSetFPSNum)); + expect(current.scaleType).to.equal(osn.EScaleType.Bicubic, GetErrorMessage(ETestErrorMsg.VideoSetScaleType)); + // a real change after the no-op re-applies must still be applied + context.video = { ...videoInfo, baseWidth: 1920, baseHeight: 1080, outputWidth: 1920, outputHeight: 1080, fpsNum: 30 }; + current = context.video; + expect(current.baseWidth).to.equal(1920, GetErrorMessage(ETestErrorMsg.VideoSetBaseWidth)); + expect(current.baseHeight).to.equal(1080, GetErrorMessage(ETestErrorMsg.VideoSetBaseHeight)); + expect(current.outputWidth).to.equal(1920, GetErrorMessage(ETestErrorMsg.VideoSetOutputWidth)); + expect(current.fpsNum).to.equal(30, GetErrorMessage(ETestErrorMsg.VideoSetFPSNum)); + context.destroy(); + }); + + it('Re-applying settings after a failed video update is not skipped', () => { + const context = osn.VideoFactory.create(); + const videoInfo: osn.IVideoInfo = { + fpsNum: 60, + fpsDen: 1, + baseWidth: 1280, + baseHeight: 720, + outputWidth: 1280, + outputHeight: 720, + outputFormat: osn.EVideoFormat.NV12, + colorspace: osn.EColorSpace.CS709, + range: osn.ERangeType.Partial, + scaleType: osn.EScaleType.Bicubic, + fpsType: EFPSType.Fractional + }; + context.video = videoInfo; + expect(() => { + context.video = { ...videoInfo, baseWidth: 0, baseHeight: 0, outputWidth: 0, outputHeight: 0 }; + }).to.throw(); + + const skippedBefore = countSkippedSetVideoCalls(); + context.video = videoInfo; + expect(countSkippedSetVideoCalls()).to.equal(skippedBefore, 'Settings were skipped after a failed update'); + const current = context.video; + expect(current.baseWidth).to.equal(1280, GetErrorMessage(ETestErrorMsg.VideoSetBaseWidth)); + expect(current.outputHeight).to.equal(720, GetErrorMessage(ETestErrorMsg.VideoSetOutputHeight)); + context.destroy(); + }); + it('Get video capture devices', function() { const devices = osn.NodeObs.OBS_settings_getVideoDevices(); expect(devices).to.not.equal(undefined, GetErrorMessage(ETestErrorMsg.VideoDevices));