Skip to content
7 changes: 7 additions & 0 deletions js/module.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<IScreenshotResult>;
OBS_content_takeScreenshot(video: IVideo[], directory: string, filenameFormat: string, noSpace?: boolean): Promise<IScreenshotResult[]>;
}
export declare const enum VCamOutputType {
Invalid = 0,
Expand Down
41 changes: 41 additions & 0 deletions js/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2489,6 +2499,37 @@ 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". Capture happens
* off the graphics thread on the next couple of frames and encoding
* happens on a background thread, so this never blocks IPC or rendering;
* the returned promise resolves once the PNG has been written.
*
* The file is named `Screenshot <filenameFormat>.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} If `video` is not an `IVideo` (or a non-empty array of them) or a string argument is missing
* @throws {Error} If a canvas has no running video, the directory does not exist, rendering,
* readback or PNG encoding fails, too many screenshots are already in flight, or the IPC call fails
*/
OBS_content_takeScreenshot(video: IVideo, directory: string, filenameFormat: string, noSpace?: boolean): Promise<IScreenshotResult>;
OBS_content_takeScreenshot(video: IVideo[], directory: string, filenameFormat: string, noSpace?: boolean): Promise<IScreenshotResult[]>;
}

export const enum VCamOutputType {
Expand Down
219 changes: 219 additions & 0 deletions obs-studio-client/source/nodeobs_display.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
#include "callback-manager.hpp"
#include "video.hpp"

#include <chrono>
#include <cstring>
#include <memory>
#include <thread>

#ifdef WIN32
static BOOL CALLBACK EnumChromeWindowsProc(HWND hwnd, LPARAM lParam)
{
Expand Down Expand Up @@ -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<ipc::value> &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::vector<uint64_t> jobIds, std::vector<uint64_t> canvasIds, bool isArrayCall)
: Napi::AsyncWorker(env),
deferred(deferred),
jobIds(std::move(jobIds)),
canvasIds(std::move(canvasIds)),
isArrayCall(isArrayCall),
results(this->jobIds.size())
{
}

void Execute() override
{
auto conn = Controller::GetInstance().GetConnection();
Comment thread
summeroff marked this conversation as resolved.
Outdated
if (!conn) {
SetError("Lost IPC connection while waiting for a screenshot.");
return;
}

std::vector<bool> 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<ipc::value> 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::vector<uint64_t> jobIds;
std::vector<uint64_t> canvasIds;
bool isArrayCall;
std::vector<Result> 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<uint64_t> canvasIds;

if (isArrayCall) {
Napi::Array videos = info[0].As<Napi::Array>();
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() ? Napi::ObjectWrap<osn::Video>::Unwrap(item.ToObject()) : nullptr;
Comment thread
summeroff marked this conversation as resolved.
Outdated
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 = Napi::ObjectWrap<osn::Video>::Unwrap(info[0].ToObject());
Comment thread
summeroff marked this conversation as resolved.
Outdated
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<char> canvasIdBytes(canvasIds.size() * sizeof(uint64_t));
memcpy(canvasIdBytes.data(), canvasIds.data(), canvasIdBytes.size());

auto conn = GetConnection(info);
if (!conn)
return env.Undefined();

std::vector<ipc::value> 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<char> &jobIdBytes = response[1].value_bin;
std::vector<uint64_t> jobIds(jobIdBytes.size() / sizeof(uint64_t));
memcpy(jobIds.data(), jobIdBytes.data(), jobIdBytes.size());

auto worker = std::make_unique<ScreenshotWaitWorker>(env, deferred, 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));
Expand All @@ -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));
}
1 change: 1 addition & 0 deletions obs-studio-client/source/nodeobs_display.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
4 changes: 4 additions & 0 deletions obs-studio-server/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions obs-studio-server/source/nodeobs_api.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1668,6 +1669,8 @@ void OBS_API::destroyOBS_API(void)
#endif
OBS_content::OBS_content_shutdownDisplays();

ScreenshotManager::GetInstance().Shutdown();

autoOptimizer::Shutdown();

OBS_service::stopAllOutputs();
Expand Down
Loading
Loading