diff --git a/js/module.d.ts b/js/module.d.ts index dc1bb5614..ecd1b9c73 100644 --- a/js/module.d.ts +++ b/js/module.d.ts @@ -670,6 +670,11 @@ export interface IVideoInfo { scaleType: EScaleType; fpsType: EFPSType; } +export interface IScreenshotResult { + path: string; + width: number; + height: number; +} export interface IVideo { video: IVideoInfo; legacySettings: IVideoInfo; @@ -1154,6 +1159,8 @@ interface INodeObs { readonly AutoOptimizer: IAutoOptimizer; OBS_API_initAPI(options: IOBSAPIInitializationOptions): EVideoCodes; OBS_settings_saveSettings(category: string, settings: any[]): void; + OBS_content_takeScreenshot(video: IVideo, directory: string, filenameFormat: string, noSpace?: boolean): Promise; + OBS_content_takeScreenshot(video: IVideo[], directory: string, filenameFormat: string, noSpace?: boolean): Promise; } export declare const enum VCamOutputType { Invalid = 0, diff --git a/js/module.ts b/js/module.ts index 44e60e698..88ba5c0da 100644 --- a/js/module.ts +++ b/js/module.ts @@ -1504,6 +1504,16 @@ export interface IVideoInfo { fpsType: EFPSType; } +/** Result of `NodeObs.OBS_content_takeScreenshot`. */ +export interface IScreenshotResult { + /** Full path of the PNG that was written. */ + path: string; + /** Image width in pixels (the canvas base width). */ + width: number; + /** Image height in pixels (the canvas base height). */ + height: number; +} + export interface IVideo { video: IVideoInfo; legacySettings: IVideoInfo; @@ -2489,6 +2499,39 @@ interface INodeObs { * @throws {Error} If the IPC request fails or the server rejects the save */ OBS_settings_saveSettings(category: string, settings: any[]): void; + + /** + * Renders the program output of `video` at its base resolution and writes + * it as a PNG, mirroring OBS Studio's "Screenshot Output". The canvas is + * rendered and staged on one graphics tick and read back on the next; + * alpha fix-up and PNG encoding run on a background thread. The call + * returns a promise without blocking on IPC, and it resolves once the PNG + * has been written. + * + * The file is named `Screenshot .png` with the OBS + * filename tokens expanded; missing subfolders in the format are created. + * When `noSpace` is true every space becomes `_`. If the name is taken, + * ` (2)`, ` (3)`, ... (or `_2`, `_3`, ...) is inserted before the + * extension. + * + * Passing an array captures every canvas from the same frame in one call. + * Each file name then also gets its canvas' base resolution appended + * (e.g. `Screenshot 2026-09-29 12-00-00 1920x1080.png`) before the normal + * dedupe suffix. If any canvas fails, the whole call rejects naming that + * canvas; screenshots already written for other canvases in the batch are + * not removed. At most 4 screenshot jobs may be in flight at once. + * @param video - Video context(s) whose main (program) mix is captured + * @param directory - Existing directory to write into + * @param filenameFormat - OBS filename formatting pattern, e.g. `%CCYY-%MM-%DD %hh-%mm-%ss` + * @param noSpace - Replace spaces in the generated file name with underscores + * @returns The path written and the image dimensions for each canvas + * @throws {TypeError} Synchronously, if `video` is not an `IVideo` (or a non-empty array of them) or a string argument is missing + * @throws {Error} Synchronously, if there is no IPC connection to the server + * The promise rejects with an `Error` if a canvas has no running video, the directory does not exist, + * rendering, readback or PNG encoding fails, more than 4 screenshots would be in flight, or the IPC call fails + */ + OBS_content_takeScreenshot(video: IVideo, directory: string, filenameFormat: string, noSpace?: boolean): Promise; + OBS_content_takeScreenshot(video: IVideo[], directory: string, filenameFormat: string, noSpace?: boolean): Promise; } export const enum VCamOutputType { diff --git a/obs-studio-client/source/nodeobs_display.cpp b/obs-studio-client/source/nodeobs_display.cpp index dee93ccf1..e99f97562 100644 --- a/obs-studio-client/source/nodeobs_display.cpp +++ b/obs-studio-client/source/nodeobs_display.cpp @@ -33,6 +33,11 @@ #include "callback-manager.hpp" #include "video.hpp" +#include +#include +#include +#include + #ifdef WIN32 static BOOL CALLBACK EnumChromeWindowsProc(HWND hwnd, LPARAM lParam) { @@ -344,6 +349,219 @@ Napi::Value display::OBS_content_createIOSurface(const Napi::CallbackInfo &info) return Napi::Number::New(info.Env(), response[1].value_union.ui32); } +namespace { + +// Extracts an IPC error without throwing, since a Submit or poll failure must +// reject the returned promise rather than throw synchronously. +bool ExtractCallError(const std::vector &response, std::string &error) +{ + if (response.empty()) { + error = "Failed to make IPC call, verify IPC status."; + return false; + } + if (response.size() == 1 && response[0].type == ipc::type::Null) { + error = response[0].value_str; + return false; + } + if ((ErrorCode)response[0].value_union.ui64 != ErrorCode::Ok) { + error = response.size() > 1 ? response[1].value_str : "Unknown screenshot error."; + return false; + } + return true; +} + +Napi::Object ScreenshotResultToObject(Napi::Env env, const std::string &path, uint32_t width, uint32_t height) +{ + Napi::Object object = Napi::Object::New(env); + object.Set("path", Napi::String::New(env, path)); + object.Set("width", Napi::Number::New(env, width)); + object.Set("height", Napi::Number::New(env, height)); + return object; +} + +// Mirrors ScreenshotManager::State on the server; the wire value is just the enum ordinal. +enum class ScreenshotJobState : uint32_t { Pending = 0, Done = 1, Failed = 2 }; + +// Polls OBS_content_getScreenshotResult instead of blocking the IPC thread on +// the server's own tick-driven capture. One worker waits for every job in a +// batch, so a single canvas failure rejects the whole call. +class ScreenshotWaitWorker : public Napi::AsyncWorker { +public: + ScreenshotWaitWorker(Napi::Env env, Napi::Promise::Deferred deferred, std::shared_ptr conn, std::vector jobIds, + std::vector canvasIds, bool isArrayCall) + : Napi::AsyncWorker(env), + deferred(deferred), + conn(std::move(conn)), + jobIds(std::move(jobIds)), + canvasIds(std::move(canvasIds)), + isArrayCall(isArrayCall), + results(this->jobIds.size()) + { + } + + void Execute() override + { + std::vector done(jobIds.size(), false); + size_t remaining = jobIds.size(); + std::string firstError; + + /* Every job must be polled to a terminal state, even after one fails, + * so the server can erase it instead of leaking a job slot. */ + while (remaining > 0) { + for (size_t i = 0; i < jobIds.size(); ++i) { + if (done[i]) + continue; + + std::vector response = + conn->call_synchronous_helper("Display", "OBS_content_getScreenshotResult", {ipc::value(jobIds[i])}); + + std::string error; + if (!ExtractCallError(response, error)) { + if (firstError.empty()) + firstError = "Screenshot failed for canvas " + std::to_string(canvasIds[i]) + ": " + error; + done[i] = true; + --remaining; + continue; + } + + const auto state = ScreenshotJobState(response[1].value_union.ui32); + if (state == ScreenshotJobState::Pending) + continue; + + if (state == ScreenshotJobState::Failed) { + if (firstError.empty()) + firstError = "Screenshot failed for canvas " + std::to_string(canvasIds[i]) + ": " + response[5].value_str; + done[i] = true; + --remaining; + continue; + } + + results[i].path = response[2].value_str; + results[i].width = response[3].value_union.ui32; + results[i].height = response[4].value_union.ui32; + done[i] = true; + --remaining; + } + + if (remaining > 0) + std::this_thread::sleep_for(std::chrono::milliseconds(16)); + } + + if (!firstError.empty()) + SetError(firstError); + } + + void OnOK() override + { + Napi::Env env = Env(); + if (!isArrayCall) { + deferred.Resolve(ScreenshotResultToObject(env, results[0].path, results[0].width, results[0].height)); + return; + } + + Napi::Array array = Napi::Array::New(env, results.size()); + for (uint32_t i = 0; i < results.size(); ++i) + array.Set(i, ScreenshotResultToObject(env, results[i].path, results[i].width, results[i].height)); + deferred.Resolve(array); + } + + void OnError(const Napi::Error &error) override { deferred.Reject(Napi::Error::New(Env(), error.Message()).Value()); } + +private: + struct Result { + std::string path; + uint32_t width = 0; + uint32_t height = 0; + }; + + Napi::Promise::Deferred deferred; + std::shared_ptr conn; + std::vector jobIds; + std::vector canvasIds; + bool isArrayCall; + std::vector results; +}; + +} // namespace + +Napi::Value display::OBS_content_takeScreenshot(const Napi::CallbackInfo &info) +{ + Napi::Env env = info.Env(); + + if (info.Length() < 3 || !info[0].IsObject() || !info[1].IsString() || !info[2].IsString()) { + Napi::TypeError::New(env, "OBS_content_takeScreenshot(video, directory, filenameFormat, noSpace?) expects a Video object " + "(or an array of them) and two strings.") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + const bool isArrayCall = info[0].IsArray(); + std::vector canvasIds; + + if (isArrayCall) { + Napi::Array videos = info[0].As(); + if (videos.Length() == 0) { + Napi::TypeError::New(env, "OBS_content_takeScreenshot: video array must not be empty.").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + canvasIds.reserve(videos.Length()); + for (uint32_t i = 0; i < videos.Length(); ++i) { + Napi::Value item = videos.Get(i); + osn::Video *video = (item.IsObject() && item.ToObject().InstanceOf(osn::Video::constructor.Value())) + ? Napi::ObjectWrap::Unwrap(item.ToObject()) + : nullptr; + if (!video) { + Napi::TypeError::New(env, "OBS_content_takeScreenshot: video array must only contain Video objects.") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + canvasIds.push_back(video->canvasId); + } + } else { + osn::Video *video = info[0].ToObject().InstanceOf(osn::Video::constructor.Value()) ? Napi::ObjectWrap::Unwrap(info[0].ToObject()) + : nullptr; + if (!video) { + Napi::TypeError::New(env, "OBS_content_takeScreenshot: first argument is not a Video object.").ThrowAsJavaScriptException(); + return env.Undefined(); + } + canvasIds.push_back(video->canvasId); + } + + std::string directory = info[1].ToString().Utf8Value(); + std::string filenameFormat = info[2].ToString().Utf8Value(); + /* ipc::value has no bool constructor; a bare bool would promote to int32 and mismatch the registered UInt32. */ + uint32_t noSpace = (info.Length() > 3 && !info[3].IsUndefined() && info[3].ToBoolean().Value()) ? 1 : 0; + + std::vector canvasIdBytes(canvasIds.size() * sizeof(uint64_t)); + memcpy(canvasIdBytes.data(), canvasIds.data(), canvasIdBytes.size()); + + auto conn = GetConnection(info); + if (!conn) + return env.Undefined(); + + std::vector response = conn->call_synchronous_helper( + "Display", "OBS_content_takeScreenshot", {ipc::value(canvasIdBytes), ipc::value(directory), ipc::value(filenameFormat), ipc::value(noSpace)}); + + auto deferred = Napi::Promise::Deferred::New(env); + + std::string submitError; + if (!ExtractCallError(response, submitError)) { + deferred.Reject(Napi::Error::New(env, submitError).Value()); + return deferred.Promise(); + } + + const std::vector &jobIdBytes = response[1].value_bin; + std::vector jobIds(jobIdBytes.size() / sizeof(uint64_t)); + memcpy(jobIds.data(), jobIdBytes.data(), jobIdBytes.size()); + + auto worker = std::make_unique(env, deferred, conn, std::move(jobIds), std::move(canvasIds), isArrayCall); + worker->Queue(); + worker.release(); + + return deferred.Promise(); +} + void display::Init(Napi::Env env, Napi::Object exports) { exports.Set(Napi::String::New(env, "OBS_content_setDayTheme"), Napi::Function::New(env, display::OBS_content_setDayTheme)); @@ -362,4 +580,5 @@ void display::Init(Napi::Env env, Napi::Object exports) exports.Set(Napi::String::New(env, "OBS_content_setDrawGuideLines"), Napi::Function::New(env, display::OBS_content_setDrawGuideLines)); exports.Set(Napi::String::New(env, "OBS_content_setDrawRotationHandle"), Napi::Function::New(env, display::OBS_content_setDrawRotationHandle)); exports.Set(Napi::String::New(env, "OBS_content_createIOSurface"), Napi::Function::New(env, display::OBS_content_createIOSurface)); + exports.Set(Napi::String::New(env, "OBS_content_takeScreenshot"), Napi::Function::New(env, display::OBS_content_takeScreenshot)); } diff --git a/obs-studio-client/source/nodeobs_display.hpp b/obs-studio-client/source/nodeobs_display.hpp index b52c4dd82..7ba9e319b 100644 --- a/obs-studio-client/source/nodeobs_display.hpp +++ b/obs-studio-client/source/nodeobs_display.hpp @@ -37,4 +37,5 @@ Napi::Value OBS_content_setShouldDrawUI(const Napi::CallbackInfo &info); Napi::Value OBS_content_setDrawGuideLines(const Napi::CallbackInfo &info); Napi::Value OBS_content_setDrawRotationHandle(const Napi::CallbackInfo &info); Napi::Value OBS_content_createIOSurface(const Napi::CallbackInfo &info); +Napi::Value OBS_content_takeScreenshot(const Napi::CallbackInfo &info); } diff --git a/obs-studio-server/CMakeLists.txt b/obs-studio-server/CMakeLists.txt index b0ab17365..2a34836e1 100644 --- a/obs-studio-server/CMakeLists.txt +++ b/obs-studio-server/CMakeLists.txt @@ -412,6 +412,10 @@ SET(osn-server_SOURCES ###### memory-manager ###### "${PROJECT_SOURCE_DIR}/source/memory-manager.cpp" "${PROJECT_SOURCE_DIR}/source/memory-manager.h" + + ###### osn-screenshot ###### + "${PROJECT_SOURCE_DIR}/source/osn-screenshot.cpp" + "${PROJECT_SOURCE_DIR}/source/osn-screenshot.hpp" ) if (APPLE) diff --git a/obs-studio-server/source/nodeobs_api.cpp b/obs-studio-server/source/nodeobs_api.cpp index 1785bb03f..70999e72d 100644 --- a/obs-studio-server/source/nodeobs_api.cpp +++ b/obs-studio-server/source/nodeobs_api.cpp @@ -42,6 +42,7 @@ #include "osn-network.hpp" #include "osn-audio-track.hpp" #include "memory-manager.h" +#include "osn-screenshot.hpp" //to destroy cpu usage object #include "osn-global.hpp" @@ -1668,6 +1669,8 @@ void OBS_API::destroyOBS_API(void) #endif OBS_content::OBS_content_shutdownDisplays(); + ScreenshotManager::GetInstance().Shutdown(); + autoOptimizer::Shutdown(); OBS_service::stopAllOutputs(); diff --git a/obs-studio-server/source/nodeobs_common.cpp b/obs-studio-server/source/nodeobs_common.cpp index b31173660..c962d7a6c 100644 --- a/obs-studio-server/source/nodeobs_common.cpp +++ b/obs-studio-server/source/nodeobs_common.cpp @@ -27,7 +27,9 @@ #include "osn-error.hpp" #include "shared.hpp" #include "osn-video.hpp" +#include "osn-screenshot.hpp" +#include #include std::map displays; @@ -218,6 +220,13 @@ void OBS_content::Register(ipc::server &srv) cls->register_function( std::make_shared("OBS_content_createIOSurface", std::vector{ipc::type::String}, OBS_content_createIOSurface)); + cls->register_function(std::make_shared( + "OBS_content_takeScreenshot", std::vector{ipc::type::Binary, ipc::type::String, ipc::type::String, ipc::type::UInt32}, + OBS_content_takeScreenshot)); + + cls->register_function( + std::make_shared("OBS_content_getScreenshotResult", std::vector{ipc::type::UInt64}, OBS_content_getScreenshotResult)); + srv.register_collection(cls); g_srv = &srv; } @@ -677,3 +686,53 @@ void OBS_content::OBS_content_createIOSurface(void *data, const int64_t id, cons #endif AUTO_DEBUG; } + +void OBS_content::OBS_content_takeScreenshot(void *data, const int64_t id, const std::vector &args, std::vector &rval) +{ + if (args.size() < 4 || args[0].value_bin.empty() || (args[0].value_bin.size() % sizeof(uint64_t)) != 0) { + rval.push_back(ipc::value((uint64_t)ErrorCode::Error)); + rval.push_back(ipc::value("takeScreenshot expects (canvasIds, directory, filenameFormat, noSpace).")); + return; + } + + std::vector canvasIds(args[0].value_bin.size() / sizeof(uint64_t)); + memcpy(canvasIds.data(), args[0].value_bin.data(), args[0].value_bin.size()); + + const std::string directory = args[1].value_str; + const std::string format = args[2].value_str; + const bool noSpace = args[3].value_union.ui32 != 0; + + std::vector jobIds; + std::string error; + if (!ScreenshotManager::GetInstance().Submit(canvasIds, directory, format, noSpace, jobIds, error)) { + rval.push_back(ipc::value((uint64_t)ErrorCode::Error)); + rval.push_back(ipc::value(error)); + return; + } + + std::vector jobIdBytes(jobIds.size() * sizeof(uint64_t)); + memcpy(jobIdBytes.data(), jobIds.data(), jobIdBytes.size()); + + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); + rval.push_back(ipc::value(jobIdBytes)); + AUTO_DEBUG; +} + +void OBS_content::OBS_content_getScreenshotResult(void *data, const int64_t id, const std::vector &args, std::vector &rval) +{ + if (args.size() < 1) { + rval.push_back(ipc::value((uint64_t)ErrorCode::Error)); + rval.push_back(ipc::value("getScreenshotResult expects (jobId).")); + return; + } + + ScreenshotManager::Result result = ScreenshotManager::GetInstance().Query(args[0].value_union.ui64); + + rval.push_back(ipc::value((uint64_t)ErrorCode::Ok)); + rval.push_back(ipc::value((uint32_t)result.state)); + rval.push_back(ipc::value(result.path)); + rval.push_back(ipc::value((uint32_t)result.width)); + rval.push_back(ipc::value((uint32_t)result.height)); + rval.push_back(ipc::value(result.error)); + AUTO_DEBUG; +} diff --git a/obs-studio-server/source/nodeobs_content.h b/obs-studio-server/source/nodeobs_content.h index 55ce91480..3dc287bd9 100644 --- a/obs-studio-server/source/nodeobs_content.h +++ b/obs-studio-server/source/nodeobs_content.h @@ -65,4 +65,6 @@ class OBS_content { static void OBS_content_setDrawGuideLines(void *data, const int64_t id, const std::vector &args, std::vector &rval); static void OBS_content_setDrawRotationHandle(void *data, const int64_t id, const std::vector &args, std::vector &rval); static void OBS_content_createIOSurface(void *data, const int64_t id, const std::vector &args, std::vector &rval); + static void OBS_content_takeScreenshot(void *data, const int64_t id, const std::vector &args, std::vector &rval); + static void OBS_content_getScreenshotResult(void *data, const int64_t id, const std::vector &args, std::vector &rval); }; diff --git a/obs-studio-server/source/osn-screenshot.cpp b/obs-studio-server/source/osn-screenshot.cpp new file mode 100644 index 000000000..2545b13d7 --- /dev/null +++ b/obs-studio-server/source/osn-screenshot.cpp @@ -0,0 +1,580 @@ +/****************************************************************************** + 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 "osn-screenshot.hpp" +#include "osn-video.hpp" + +#include +#include +#include + +namespace { +constexpr size_t kMaxActiveJobs = 4; +constexpr auto kJobDeadline = std::chrono::seconds(10); +constexpr auto kJobGracePeriod = std::chrono::seconds(5); + +std::string JoinPath(const std::string &directory, const std::string &file) +{ + if (directory.empty()) + return file; + const char last = directory.back(); + if (last == '/' || last == '\\') + return directory + file; + return directory + "/" + file; +} + +std::string DirName(const std::string &path) +{ + const size_t pos = path.find_last_of("/\\"); + return pos == std::string::npos ? std::string() : path.substr(0, pos); +} + +/* Same behaviour as OBS Studio's FindBestFilename: append " (2)", " (3)"... + * or "_2", "_3"... before the extension until the name is free. */ +void FindBestScreenshotFilename(std::string &strPath, bool noSpace) +{ + int num = 2; + + if (!os_file_exists(strPath.c_str())) + return; + + const char *ext = strrchr(strPath.c_str(), '.'); + if (!ext) + return; + + int extStart = int(ext - strPath.c_str()); + for (;;) { + std::string testPath = strPath; + std::string numStr = noSpace ? "_" : " ("; + numStr += std::to_string(num++); + if (!noSpace) + numStr += ")"; + + testPath.insert(extStart, numStr); + + if (!os_file_exists(testPath.c_str())) { + strPath = testPath; + break; + } + } +} + +/* OBS: GetOutputFilename(rec_path, "png", noSpace, overwrite = false, + * GetFormatString(format, "Screenshot", nullptr)). The prefix is inserted in + * front of the format; os_generate_formatted_filename(space = false) then + * turns every space into '_', which is why noSpace yields "Screenshot_...". */ +bool GenerateFormattedName(obs_video_info *canvas, const std::string &format, bool noSpace, bool multiCanvas, std::string &outName, std::string &error) +{ + const std::string fullFormat = "Screenshot " + format; + char *filename = os_generate_formatted_filename("png", !noSpace, fullFormat.c_str(), canvas); + if (!filename || !*filename) { + bfree(filename); + error = "Failed to generate a screenshot filename from format '" + format + "'."; + return false; + } + + outName = filename; + bfree(filename); + + if (multiCanvas) { + std::string suffix = noSpace ? "_" : " "; + suffix += std::to_string(canvas->base_width) + "x" + std::to_string(canvas->base_height); + const size_t ext = outName.rfind(".png"); + outName.insert(ext == std::string::npos ? outName.size() : ext, suffix); + } + return true; +} +} // namespace + +ScreenshotManager &ScreenshotManager::GetInstance() +{ + static ScreenshotManager instance; + return instance; +} + +bool ScreenshotManager::Submit(const std::vector &canvasIds, const std::string &directory, const std::string &format, bool noSpace, + std::vector &jobIds, std::string &error) +{ + if (canvasIds.empty()) { + error = "takeScreenshot requires at least one canvas id."; + return false; + } + if (directory.empty() || !os_file_exists(directory.c_str())) { + error = "Screenshot directory does not exist: " + directory; + return false; + } + + const bool multiCanvas = canvasIds.size() > 1; + + struct Prepared { + uint64_t canvasId; + obs_video_info *canvas; + std::string name; + }; + std::vector prepared; + prepared.reserve(canvasIds.size()); + + for (uint64_t canvasId : canvasIds) { + obs_video_info *canvas = osn::Video::Manager::GetInstance().find(canvasId); + if (!canvas) { + error = "Invalid canvas id provided to takeScreenshot: " + std::to_string(canvasId); + return false; + } + if (canvas->base_width == 0 || canvas->base_height == 0) { + error = "Canvas " + std::to_string(canvasId) + " has no base resolution."; + return false; + } + if (!obs_video_mix_get(canvas, OBS_MAIN_VIDEO_RENDERING)) { + error = "Canvas " + std::to_string(canvasId) + " has no active video mix; video must be running to take a screenshot."; + return false; + } + + std::string name; + if (!GenerateFormattedName(canvas, format, noSpace, multiCanvas, name, error)) + return false; + + prepared.push_back({canvasId, canvas, std::move(name)}); + } + + bool needsTickCallback = false; + { + std::lock_guard lock(m_mutex); + PruneStaleJobsLocked(); + + size_t activeCount = m_encodesInFlight; + for (auto &item : m_jobs) { + if (!item.second.abandoned && item.second.renderStage != RenderStage::Handed) + ++activeCount; + } + if (activeCount + prepared.size() > kMaxActiveJobs) { + error = "busy: too many screenshots in flight (max " + std::to_string(kMaxActiveJobs) + ")"; + return false; + } + + jobIds.clear(); + jobIds.reserve(prepared.size()); + const auto deadline = std::chrono::steady_clock::now() + kJobDeadline; + for (auto &p : prepared) { + const uint64_t jobId = m_nextJobId++; + Job &job = m_jobs[jobId]; + job.canvasId = p.canvasId; + job.canvas = p.canvas; + job.width = p.canvas->base_width; + job.height = p.canvas->base_height; + job.directory = directory; + job.formattedName = p.name; + job.noSpace = noSpace; + job.deadline = deadline; + jobIds.push_back(jobId); + } + + needsTickCallback = !m_tickRegistered; + m_tickRegistered = true; + EnsureEncoderStartedLocked(); + } + + // obs_add_tick_callback must run outside m_mutex; see the lock-order note + // on m_mutex in the header. + if (needsTickCallback) + obs_add_tick_callback(&ScreenshotManager::Tick, this); + + return true; +} + +ScreenshotManager::Result ScreenshotManager::Query(uint64_t jobId) +{ + Result result; + std::lock_guard lock(m_mutex); + + auto it = m_jobs.find(jobId); + if (it == m_jobs.end()) { + result.state = State::Failed; + result.error = "Unknown screenshot job."; + return result; + } + + Job &job = it->second; + if (job.state == State::Pending && std::chrono::steady_clock::now() >= job.deadline) { + job.state = State::Failed; + job.error = "Screenshot timed out."; + job.abandoned = true; + } + + result.state = job.state; + result.path = job.path; + result.width = job.width; + result.height = job.height; + result.error = job.error; + + /* Once GPU resources are gone this job has nothing left to report; a job + * that timed out while still staged is left for RunTick to free and erase. */ + if (job.state != State::Pending && !job.texrender && !job.stagesurf && !job.tickBusy) + m_jobs.erase(it); + + return result; +} + +void ScreenshotManager::PruneStaleJobsLocked() +{ + const auto now = std::chrono::steady_clock::now(); + for (auto it = m_jobs.begin(); it != m_jobs.end();) { + Job &job = it->second; + if (now < job.deadline + kJobGracePeriod) { + ++it; + continue; + } + + if (job.state == State::Pending) { + job.state = State::Failed; + job.error = "Screenshot timed out."; + } + + /* Still owns GPU objects, or RunTick is working on it: only RunTick may free those. */ + if (job.texrender || job.stagesurf || job.tickBusy) { + job.abandoned = true; + ++it; + } else { + it = m_jobs.erase(it); + } + } +} + +void ScreenshotManager::EnsureEncoderStartedLocked() +{ + if (!m_encoderThread.joinable()) { + m_encoderStop = false; + m_encoderThread = std::thread(&ScreenshotManager::EncoderThreadMain, this); + } +} + +void ScreenshotManager::Tick(void *param, float) +{ + static_cast(param)->RunTick(); +} + +void ScreenshotManager::RunTick() +{ + struct StageWork { + uint64_t jobId; + uint64_t canvasId; + obs_video_info *canvas; + uint32_t width; + uint32_t height; + gs_texrender_t *texrender = nullptr; + gs_stagesurf_t *stagesurf = nullptr; + std::string error; + }; + struct MapWork { + uint64_t jobId; + uint32_t width; + uint32_t height; + gs_texrender_t *texrender; + gs_stagesurf_t *stagesurf; + std::vector pixels; + bool mapped = false; + }; + struct GpuObjects { + gs_texrender_t *texrender; + gs_stagesurf_t *stagesurf; + }; + + auto destroyGpuObjects = [](const std::vector &objects) { + if (objects.empty()) + return; + obs_enter_graphics(); + for (const GpuObjects &item : objects) { + if (item.stagesurf) + gs_stagesurface_destroy(item.stagesurf); + if (item.texrender) + gs_texrender_destroy(item.texrender); + } + obs_leave_graphics(); + }; + + std::vector stageWork; + std::vector mapWork; + std::vector abandonedObjects; + + /* Jobs handed to the work vectors are marked tickBusy so Query and + * PruneStaleJobsLocked only flag them abandoned while the tick works on + * them without m_mutex. */ + { + std::lock_guard lock(m_mutex); + for (auto it = m_jobs.begin(); it != m_jobs.end();) { + Job &job = it->second; + + if (job.abandoned) { + if (job.texrender || job.stagesurf) + abandonedObjects.push_back({job.texrender, job.stagesurf}); + it = m_jobs.erase(it); + continue; + } + + if (job.state == State::Pending && job.renderStage == RenderStage::NotStarted) { + job.tickBusy = true; + stageWork.push_back({it->first, job.canvasId, job.canvas, job.width, job.height}); + } else if (job.state == State::Pending && job.renderStage == RenderStage::Staged) { + job.tickBusy = true; + mapWork.push_back({it->first, job.width, job.height, job.texrender, job.stagesurf, {}}); + } + ++it; + } + } + + if (stageWork.empty() && mapWork.empty() && abandonedObjects.empty()) + return; + + obs_enter_graphics(); + + for (GpuObjects &item : abandonedObjects) { + if (item.stagesurf) + gs_stagesurface_destroy(item.stagesurf); + if (item.texrender) + gs_texrender_destroy(item.texrender); + } + abandonedObjects.clear(); + + for (StageWork &work : stageWork) { + /* Frame N: stage the render, queueing a GPU copy that lands by + * next tick instead of stalling here to wait for it. */ + obs_video_info *current = osn::Video::Manager::GetInstance().find(work.canvasId); + if (!current || current != work.canvas) { + work.error = "Canvas removed before the screenshot could be rendered."; + continue; + } + + gs_texrender_t *texrender = gs_texrender_create(GS_RGBA, GS_ZS_NONE); + if (!texrender) { + work.error = "Failed to create texture renderer."; + continue; + } + if (!gs_texrender_begin_with_color_space(texrender, work.width, work.height, GS_CS_SRGB)) { + gs_texrender_destroy(texrender); + work.error = "Failed to begin texture render."; + continue; + } + + vec4 black; + vec4_set(&black, 0.0f, 0.0f, 0.0f, 1.0f); + gs_clear(GS_CLEAR_COLOR, &black, 0.0f, 0); + + gs_viewport_push(); + gs_projection_push(); + gs_ortho(0.0f, float(work.width), 0.0f, float(work.height), -100.0f, 100.0f); + gs_set_viewport(0, 0, int(work.width), int(work.height)); + + gs_blend_state_push(); + gs_blend_function(GS_BLEND_ONE, GS_BLEND_ZERO); + + obs_render_texture(work.canvas, OBS_MAIN_VIDEO_RENDERING); + + gs_blend_state_pop(); + gs_projection_pop(); + gs_viewport_pop(); + gs_texrender_end(texrender); + + gs_texture_t *tex = gs_texrender_get_texture(texrender); + gs_stagesurf_t *stagesurf = tex ? gs_stagesurface_create(work.width, work.height, GS_RGBA) : nullptr; + if (!stagesurf) { + gs_texrender_destroy(texrender); + work.error = "Failed to create staging surface."; + continue; + } + + gs_stage_texture(stagesurf, tex); + work.texrender = texrender; + work.stagesurf = stagesurf; + } + + /* Frame N+1: the staged copy has landed, so this map does not stall. */ + for (MapWork &work : mapWork) { + uint8_t *data = nullptr; + uint32_t linesize = 0; + if (gs_stagesurface_map(work.stagesurf, &data, &linesize)) { + const size_t rowBytes = size_t(work.width) * 4; + work.pixels.resize(rowBytes * work.height); + for (uint32_t y = 0; y < work.height; ++y) { + const uint8_t *src = data + size_t(y) * linesize; + memcpy(work.pixels.data() + size_t(y) * rowBytes, src, rowBytes); + } + gs_stagesurface_unmap(work.stagesurf); + work.mapped = true; + } + + gs_stagesurface_destroy(work.stagesurf); + gs_texrender_destroy(work.texrender); + work.stagesurf = nullptr; + work.texrender = nullptr; + } + + obs_leave_graphics(); + + std::vector orphanedObjects; + { + std::lock_guard lock(m_mutex); + + for (StageWork &work : stageWork) { + auto it = m_jobs.find(work.jobId); + if (it == m_jobs.end()) { + orphanedObjects.push_back({work.texrender, work.stagesurf}); + continue; + } + + Job &job = it->second; + job.tickBusy = false; + if (job.abandoned) { + orphanedObjects.push_back({work.texrender, work.stagesurf}); + m_jobs.erase(it); + } else if (!work.error.empty()) { + job.state = State::Failed; + job.error = work.error; + } else { + job.texrender = work.texrender; + job.stagesurf = work.stagesurf; + job.renderStage = RenderStage::Staged; + } + } + + for (MapWork &work : mapWork) { + auto it = m_jobs.find(work.jobId); + if (it == m_jobs.end()) + continue; + + Job &job = it->second; + job.tickBusy = false; + job.texrender = nullptr; + job.stagesurf = nullptr; + job.renderStage = RenderStage::Handed; + + if (job.abandoned) { + m_jobs.erase(it); + } else if (!work.mapped) { + job.state = State::Failed; + job.error = "Failed to map staging surface."; + } else { + EncodeTask task; + task.jobId = work.jobId; + task.directory = job.directory; + task.formattedName = job.formattedName; + task.noSpace = job.noSpace; + task.width = work.width; + task.height = work.height; + task.pixels = std::move(work.pixels); + m_encodeQueue.push_back(std::move(task)); + ++m_encodesInFlight; + m_encoderCv.notify_one(); + } + } + } + + destroyGpuObjects(orphanedObjects); +} + +void ScreenshotManager::EncoderThreadMain() +{ + std::unique_lock lock(m_mutex); + for (;;) { + m_encoderCv.wait(lock, [this] { return m_encoderStop || !m_encodeQueue.empty(); }); + if (m_encoderStop) + return; + + EncodeTask task = std::move(m_encodeQueue.front()); + m_encodeQueue.pop_front(); + lock.unlock(); + + std::string path = JoinPath(task.directory, task.formattedName); + std::string error; + bool ok = true; + + const std::string dir = DirName(path); + if (!dir.empty() && os_mkdirs(dir.c_str()) == MKDIR_ERROR) { + error = "Failed to create screenshot directory '" + dir + "'."; + ok = false; + } + + if (ok) { + /* Force alpha opaque, matching OBS's QImage::Format_RGBX8888 save path. */ + for (size_t i = 3; i < task.pixels.size(); i += 4) + task.pixels[i] = 0xFF; + + FindBestScreenshotFilename(path, task.noSpace); + if (!gs_save_png_file(path.c_str(), task.pixels.data(), GS_RGBA, task.width, task.height, task.width * 4)) { + error = "Failed to write PNG '" + path + "'."; + ok = false; + } + } + + if (ok) + blog(LOG_INFO, "[SCREENSHOT] Saved %ux%u screenshot to '%s'", task.width, task.height, path.c_str()); + else + blog(LOG_ERROR, "[SCREENSHOT] %s", error.c_str()); + + lock.lock(); + --m_encodesInFlight; + auto it = m_jobs.find(task.jobId); + if (it != m_jobs.end()) { + it->second.state = ok ? State::Done : State::Failed; + it->second.path = path; + it->second.error = error; + } + } +} + +void ScreenshotManager::Shutdown() +{ + bool tickWasRegistered = false; + { + std::lock_guard lock(m_mutex); + tickWasRegistered = m_tickRegistered; + m_tickRegistered = false; + } + // obs_remove_tick_callback blocks until any tick in progress finishes, and + // that tick needs m_mutex, so it must not be held here. + if (tickWasRegistered) + obs_remove_tick_callback(&ScreenshotManager::Tick, this); + + { + std::lock_guard lock(m_mutex); + m_encoderStop = true; + } + m_encoderCv.notify_all(); + if (m_encoderThread.joinable()) + m_encoderThread.join(); + m_encoderThread = std::thread(); + + std::vector> leftovers; + { + std::lock_guard lock(m_mutex); + m_encodeQueue.clear(); + m_encodesInFlight = 0; + for (auto &item : m_jobs) + leftovers.emplace_back(item.second.texrender, item.second.stagesurf); + m_jobs.clear(); + } + + if (!leftovers.empty()) { + obs_enter_graphics(); + for (auto &item : leftovers) { + if (item.second) + gs_stagesurface_destroy(item.second); + if (item.first) + gs_texrender_destroy(item.first); + } + obs_leave_graphics(); + } +} diff --git a/obs-studio-server/source/osn-screenshot.hpp b/obs-studio-server/source/osn-screenshot.hpp new file mode 100644 index 000000000..70d31694a --- /dev/null +++ b/obs-studio-server/source/osn-screenshot.hpp @@ -0,0 +1,135 @@ +/****************************************************************************** + 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 +#include +#include +#include + +// Renders one or more canvases to PNG without blocking the IPC or graphics +// threads. A tick callback stages each canvas' main mix over two frames (queue +// the GPU copy, then map it once it has landed) and hands the pixels to a +// dedicated encoder thread; the IPC thread only submits jobs and polls their +// state. Mirrors OBS Studio's own multi-tick "Screenshot Output" +// (frontend/utility/ScreenshotObj.cpp), adapted to run off the Qt thread. +class ScreenshotManager { +public: + enum class State : uint32_t { Pending = 0, Done = 1, Failed = 2 }; + + struct Result { + State state = State::Pending; + std::string path; + uint32_t width = 0; + uint32_t height = 0; + std::string error; + }; + + static ScreenshotManager &GetInstance(); + + // Validates every canvas id and reserves a job for each, all picked up by + // the same tick. Fails atomically (no jobs are created) if a canvas is + // invalid or has no active mix, the directory is missing, or accepting + // the whole batch would exceed the in-flight job cap. + bool Submit(const std::vector &canvasIds, const std::string &directory, const std::string &format, bool noSpace, + std::vector &jobIds, std::string &error); + + // Returns the current state of a job. Once a terminal result (Done or + // Failed) has been reported and its GPU resources (if any) are freed, the + // job is erased; querying it again reports Failed with an "unknown job" + // error, so each job must only be queried until it turns terminal. + Result Query(uint64_t jobId); + + // Removes the tick callback, stops and joins the encoder thread, then + // frees any GPU objects still owned by in-flight jobs. Call once, after + // OBS_content_shutdownDisplays and before obs_shutdown(); not safe to + // call from the graphics thread or the encoder thread. + void Shutdown(); + +private: + enum class RenderStage { NotStarted, Staged, Handed }; + + struct Job { + uint64_t canvasId = 0; + obs_video_info *canvas = nullptr; // Pointer identity check only; never dereferenced outside a tick. + uint32_t width = 0; + uint32_t height = 0; + std::string directory; + std::string formattedName; + bool noSpace = false; + std::chrono::steady_clock::time_point deadline; + + State state = State::Pending; + RenderStage renderStage = RenderStage::NotStarted; + std::string path; + std::string error; + bool abandoned = false; + bool tickBusy = false; // RunTick is working on this job without m_mutex; never erase it meanwhile. + + gs_texrender_t *texrender = nullptr; + gs_stagesurf_t *stagesurf = nullptr; + }; + + struct EncodeTask { + uint64_t jobId = 0; + std::string directory; + std::string formattedName; + bool noSpace = false; + uint32_t width = 0; + uint32_t height = 0; + std::vector pixels; + }; + + ScreenshotManager() = default; + ScreenshotManager(const ScreenshotManager &) = delete; + ScreenshotManager &operator=(const ScreenshotManager &) = delete; + + // Drops jobs whose deadline is more than kJobGracePeriod behind, whether + // pending or terminal, so a caller that stops polling (or fails partway + // through a batch) can't hold job slots forever. Call under m_mutex. + void PruneStaleJobsLocked(); + void EnsureEncoderStartedLocked(); + static void Tick(void *param, float seconds); + void RunTick(); + void EncoderThreadMain(); + + // Protects everything below. It is never held while in graphics: RunTick + // and Shutdown collect work under it, release it, then enter graphics, and + // only the tick creates or destroys a job's GPU objects. + // obs_add_tick_callback/obs_remove_tick_callback must never be called + // while holding m_mutex: a tick runs under libobs' draw_callbacks_mutex, + // so doing so risks an ABBA deadlock against a tick that is itself + // waiting on m_mutex. + std::mutex m_mutex; + std::map m_jobs; + uint64_t m_nextJobId = 1; + bool m_tickRegistered = false; + + std::thread m_encoderThread; + std::condition_variable m_encoderCv; + std::deque m_encodeQueue; + bool m_encoderStop = false; + size_t m_encodesInFlight = 0; // Queued plus currently encoding; counts toward the job cap. +}; diff --git a/tests/osn-tests/src/test_osn_video.ts b/tests/osn-tests/src/test_osn_video.ts index 163c2625d..6d323badb 100644 --- a/tests/osn-tests/src/test_osn_video.ts +++ b/tests/osn-tests/src/test_osn_video.ts @@ -1,4 +1,7 @@ import 'mocha'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { expect } from 'chai'; import * as osn from '../osn'; import { logInfo, logEmptyLine } from '../util/logger'; @@ -202,4 +205,169 @@ describe(testName, () => { logInfo(testName, `Video Capture Device Found: ${device.description} with id: ${device.id}`); } }); + + describe('Take screenshot', () => { + const screenshotFormat = '%CCYY-%MM-%DD %hh-%mm-%ss'; + let context: osn.IVideo; + let dir: string; + + // Reads the IHDR chunk of a PNG: 8-byte signature, 4-byte length, 4-byte "IHDR", then width and height. + function readPngSize(file: string): { width: number; height: number } { + const bytes = fs.readFileSync(file); + expect(bytes.length).to.be.greaterThan(24, 'PNG is too short to hold a header'); + expect(bytes.subarray(0, 8)).to.deep.equal(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 'PNG signature mismatch'); + expect(bytes.subarray(12, 16).toString('ascii')).to.equal('IHDR', 'first PNG chunk is not IHDR'); + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; + } + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'osn-screenshot-')); + context = osn.VideoFactory.create(); + context.video = { + fpsNum: 30, + 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.Bilinear, + fpsType: osn.EFPSType.Fractional, + }; + }); + + afterEach(() => { + context.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('Writes a PNG of the canvas at base resolution into the directory', async () => { + const result = await osn.NodeObs.OBS_content_takeScreenshot(context, dir, screenshotFormat, false); + + expect(result).to.not.equal(undefined, 'takeScreenshot returned nothing'); + expect(path.dirname(result.path)).to.equal(dir, 'screenshot was written outside the requested directory'); + expect(path.basename(result.path)).to.match(/^Screenshot \d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2}\.png$/); + expect(result.width).to.equal(1280); + expect(result.height).to.equal(720); + + const size = readPngSize(result.path); + expect(size.width).to.equal(1280, 'PNG width does not match the canvas base width'); + expect(size.height).to.equal(720, 'PNG height does not match the canvas base height'); + }); + + it('Never overwrites: a second screenshot with the same name gets a " (2)" suffix', async () => { + // Same second, so the same generated name; the second call must wait for the + // first file to land or the dedupe check will not see it yet. + const first = await osn.NodeObs.OBS_content_takeScreenshot(context, dir, 'same-name', false); + const second = await osn.NodeObs.OBS_content_takeScreenshot(context, dir, 'same-name', false); + + expect(path.basename(first.path)).to.equal('Screenshot same-name.png'); + expect(path.basename(second.path)).to.equal('Screenshot same-name (2).png'); + expect(fs.existsSync(first.path)).to.equal(true); + expect(fs.existsSync(second.path)).to.equal(true); + }); + + it('Replaces spaces with underscores when noSpace is set', async () => { + const result = await osn.NodeObs.OBS_content_takeScreenshot(context, dir, 'no space', true); + expect(path.basename(result.path)).to.equal('Screenshot_no_space.png'); + + const again = await osn.NodeObs.OBS_content_takeScreenshot(context, dir, 'no space', true); + expect(path.basename(again.path)).to.equal('Screenshot_no_space_2.png'); + }); + + it('Rejects when the directory does not exist', async () => { + const missing = path.join(dir, 'does-not-exist'); + let error: Error; + try { + await osn.NodeObs.OBS_content_takeScreenshot(context, missing, screenshotFormat, false); + } catch (e) { + error = e; + } + expect(error).to.not.equal(undefined, 'expected the promise to reject'); + }); + + it('Throws when the first argument is not a video context', () => { + // N-API rejects the unwrap of a plain object ("Invalid argument") before the binding's own check runs. + expect(() => osn.NodeObs.OBS_content_takeScreenshot({} as any, dir, screenshotFormat, false)).to.throw( + /Invalid argument|not a Video object/, + ); + }); + + it('Throws synchronously when given an empty array of canvases', () => { + expect(() => osn.NodeObs.OBS_content_takeScreenshot([], dir, screenshotFormat, false)).to.throw(/must not be empty/); + }); + + it('Returns a pending promise immediately instead of blocking the caller', async () => { + const callStart = Date.now(); + const promise = osn.NodeObs.OBS_content_takeScreenshot(context, dir, screenshotFormat, false); + const callDuration = Date.now() - callStart; + + expect(promise).to.be.instanceOf(Promise); + expect(callDuration).to.be.lessThan(500, 'takeScreenshot blocked the caller instead of returning a pending promise'); + + const result = await promise; + expect(fs.existsSync(result.path)).to.equal(true); + }); + + it('Creates a missing subfolder named by the filename format', async () => { + const result = await osn.NodeObs.OBS_content_takeScreenshot(context, dir, 'subdir/%CCYY-%MM-%DD %hh-%mm-%ss', false); + + expect(fs.existsSync(result.path)).to.equal(true); + expect(path.dirname(result.path)).to.not.equal(dir, 'expected a subfolder to have been created'); + expect(fs.existsSync(path.dirname(result.path))).to.equal(true); + }); + + it('Captures every canvas from one call and suffixes each name with its resolution', async () => { + const secondContext = osn.VideoFactory.create(); + secondContext.video = { + fpsNum: 30, + fpsDen: 1, + baseWidth: 640, + baseHeight: 480, + outputWidth: 640, + outputHeight: 480, + outputFormat: osn.EVideoFormat.NV12, + colorspace: osn.EColorSpace.CS709, + range: osn.ERangeType.Partial, + scaleType: osn.EScaleType.Bilinear, + fpsType: osn.EFPSType.Fractional, + }; + + try { + const results = await osn.NodeObs.OBS_content_takeScreenshot([context, secondContext], dir, screenshotFormat, false); + + expect(results).to.have.lengthOf(2); + expect(path.basename(results[0].path)).to.match(/^Screenshot \d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2} 1280x720\.png$/); + expect(path.basename(results[1].path)).to.match(/^Screenshot \d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2} 640x480\.png$/); + + const firstSize = readPngSize(results[0].path); + expect(firstSize.width).to.equal(1280); + expect(firstSize.height).to.equal(720); + + const secondSize = readPngSize(results[1].path); + expect(secondSize.width).to.equal(640); + expect(secondSize.height).to.equal(480); + } finally { + secondContext.destroy(); + } + }); + + it('Rejects a batch larger than the 4-job limit without writing any file', async () => { + let error: Error; + try { + await osn.NodeObs.OBS_content_takeScreenshot([context, context, context, context, context], dir, screenshotFormat, false); + } catch (e) { + error = e; + } + expect(error).to.not.equal(undefined, 'expected the promise to reject'); + expect(error.message).to.match(/busy/); + expect(fs.readdirSync(dir)).to.have.lengthOf(0, 'a rejected batch must not write any screenshot'); + + const result = await osn.NodeObs.OBS_content_takeScreenshot(context, dir, screenshotFormat, false); + expect(fs.existsSync(result.path)).to.equal(true); + expect(fs.readdirSync(dir)).to.have.lengthOf(1); + }); + }); });