3 Commits

Author SHA1 Message Date
93aa8fa981 NDI output
Some checks failed
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Failing after 1m47s
CI / Windows Release Package (push) Has been skipped
2026-05-22 16:41:24 +10:00
4e6b37304f NDI related tests 2026-05-22 16:34:20 +10:00
cec8b76f61 schema settings 2026-05-22 16:31:49 +10:00
15 changed files with 734 additions and 73 deletions

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"json.schemas": [
{
"fileMatch": [
"/config/runtime-host.json",
"/config/runtime-host.*.json"
],
"url": "./config/runtime-host.schema.json"
}
]
}

View File

@@ -1,18 +1,19 @@
{ {
"$schema": "./runtime-host.schema.json",
"shaderLibrary": "shaders", "shaderLibrary": "shaders",
"serverPort": 8080, "serverPort": 8080,
"oscBindAddress": "0.0.0.0", "oscBindAddress": "0.0.0.0",
"oscPort": 9000, "oscPort": 9000,
"oscSmoothing": 0.18, "oscSmoothing": 0.18,
"input": { "input": {
"backend": "ndi", "backend": "none",
"device": "AIDENLAPTOP (NVIDIA GeForce RTX 4090 Laptop GPU 1)", "device": "AIDENLAPTOP (NVIDIA GeForce RTX 4090 Laptop GPU 1)",
"resolution": "1080p", "resolution": "1080p",
"frameRate": "59.94" "frameRate": "59.94"
}, },
"output": { "output": {
"backend": "decklink", "backend": "ndi",
"device": "default", "device": "shader toys",
"resolution": "1080p", "resolution": "1080p",
"frameRate": "59.94", "frameRate": "59.94",
"keying": { "keying": {

View File

@@ -0,0 +1,211 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://render-cadence.local/schemas/runtime-host.schema.json",
"title": "Render Cadence Runtime Host Configuration",
"description": "Startup configuration for the Render Cadence native host. This schema documents the settings currently read by AppConfigProvider.",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"description": "Editor-only schema reference. The native host ignores this field."
},
"shaderLibrary": {
"type": "string",
"default": "shaders",
"description": "Path to the shader package library directory. Relative paths are resolved from the process/repo working location."
},
"serverPort": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 8080,
"description": "Preferred HTTP control server port."
},
"oscBindAddress": {
"type": "string",
"default": "0.0.0.0",
"description": "OSC bind address reserved for the control surface. The current native host exposes this in state but does not start the OSC listener yet."
},
"oscPort": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 9000,
"description": "OSC UDP port reserved for the control surface."
},
"oscSmoothing": {
"type": "number",
"minimum": 0,
"default": 0.18,
"description": "Reserved OSC smoothing amount exposed in runtime state."
},
"input": {
"$ref": "#/$defs/input"
},
"output": {
"$ref": "#/$defs/output"
},
"autoReload": {
"type": "boolean",
"default": true,
"description": "When true, the runtime control layer may automatically rescan/reload shader packages when requested by the app flow."
},
"maxTemporalHistoryFrames": {
"type": "integer",
"minimum": 0,
"default": 12,
"description": "Maximum temporal history frames exposed to shaders that request history."
},
"previewEnabled": {
"type": "boolean",
"default": false,
"description": "Starts the optional preview window on its own thread."
},
"previewFps": {
"type": "number",
"exclusiveMinimum": 0,
"default": 59.94,
"description": "Target repaint rate for the optional preview window. It does not change render/output cadence."
}
},
"required": [
"shaderLibrary",
"serverPort",
"input",
"output"
],
"$defs": {
"inputBackend": {
"type": "string",
"enum": [
"decklink",
"ndi",
"none",
"disabled",
"off"
]
},
"outputBackend": {
"type": "string",
"enum": [
"decklink",
"ndi",
"none",
"disabled",
"off"
]
},
"resolution": {
"type": "string",
"enum": [
"720p",
"1080i",
"1080p",
"2160p",
"4k",
"uhd"
]
},
"frameRate": {
"type": "string",
"enum": [
"23.98",
"24",
"25",
"29.97",
"30",
"50",
"59.94",
"60"
]
},
"input": {
"type": "object",
"additionalProperties": false,
"description": "Video input backend configuration. DeckLink uses the configured resolution/frameRate as a capture mode. NDI adapts to the received source shape and logs if it differs from these expected values.",
"properties": {
"backend": {
"$ref": "#/$defs/inputBackend",
"default": "decklink",
"description": "Input backend. Use 'decklink' for Blackmagic capture, 'ndi' for NDI receive, or 'none'/'disabled'/'off' for black fallback input."
},
"device": {
"type": "string",
"default": "default",
"description": "Input device/source selector. DeckLink currently uses 'default'. NDI accepts 'default'/'auto' for the first discovered source or an exact NDI source name."
},
"resolution": {
"$ref": "#/$defs/resolution",
"default": "1080p",
"description": "Expected input resolution/mode. For NDI this is advisory only; received source dimensions are used."
},
"frameRate": {
"$ref": "#/$defs/frameRate",
"default": "59.94",
"description": "Expected input frame rate. DeckLink uses this to resolve the hardware mode; NDI currently treats it as an expectation for logging/state."
}
},
"required": [
"backend",
"device",
"resolution",
"frameRate"
]
},
"output": {
"type": "object",
"additionalProperties": false,
"description": "Video output backend configuration.",
"properties": {
"backend": {
"$ref": "#/$defs/outputBackend",
"default": "decklink",
"description": "Output backend. Use 'decklink' for Blackmagic playout, 'ndi' for NDI send, or 'none'/'disabled'/'off' to disable scheduled output."
},
"device": {
"type": "string",
"default": "default",
"description": "Output device selector. DeckLink currently uses the first compatible/default output device. NDI uses this as the advertised sender name; 'default' becomes 'Render Cadence'."
},
"resolution": {
"$ref": "#/$defs/resolution",
"default": "1080p",
"description": "Output render and video mode resolution."
},
"frameRate": {
"$ref": "#/$defs/frameRate",
"default": "59.94",
"description": "Output render cadence and video mode frame rate."
},
"keying": {
"type": "object",
"additionalProperties": false,
"description": "DeckLink keying options.",
"properties": {
"external": {
"type": "boolean",
"default": false,
"description": "Requests DeckLink external keying when the selected output device supports it."
},
"alphaRequired": {
"type": "boolean",
"default": false,
"description": "Requires alpha-capable output format support during DeckLink output setup."
}
},
"required": [
"external",
"alphaRequired"
]
}
},
"required": [
"backend",
"device",
"resolution",
"frameRate"
]
}
}
}

View File

@@ -162,6 +162,8 @@ private:
return; return;
} }
if (mOutput->RequiresPreroll())
{
Log("app", "Starting video output thread."); Log("app", "Starting video output thread.");
if (!mOutputThread.Start()) if (!mOutputThread.Start())
{ {
@@ -172,7 +174,7 @@ private:
Log("app", "Waiting for video output preroll frames."); Log("app", "Waiting for video output preroll frames.");
if (!WaitForPreroll()) if (!WaitForPreroll())
{ {
DisableVideoOutput("Timed out waiting for DeckLink preroll frames."); DisableVideoOutput("Timed out waiting for video output preroll frames.");
return; return;
} }
@@ -182,6 +184,21 @@ private:
DisableVideoOutput("Scheduled video playback failed: " + outputError); DisableVideoOutput("Scheduled video playback failed: " + outputError);
return; return;
} }
}
else
{
Log("app", "Starting video output backend without preroll.");
if (!mOutput->StartScheduledPlayback(outputError))
{
DisableVideoOutput("Video output failed to start: " + outputError);
return;
}
if (!mOutputThread.Start())
{
DisableVideoOutput("Video output thread failed to start.");
return;
}
}
mVideoOutputEnabled = true; mVideoOutputEnabled = true;
mVideoOutputStatus = mConfig.output.backend + " scheduled output running."; mVideoOutputStatus = mConfig.output.backend + " scheduled output running.";

View File

@@ -6,6 +6,7 @@
#include "video/decklink/DeckLinkInputThread.h" #include "video/decklink/DeckLinkInputThread.h"
#include "video/decklink/DeckLinkOutput.h" #include "video/decklink/DeckLinkOutput.h"
#include "video/ndi/NdiInput.h" #include "video/ndi/NdiInput.h"
#include "video/ndi/NdiOutput.h"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -58,7 +59,7 @@ struct VideoInputBackendStartContext
bool inputVideoModeResolved = false; bool inputVideoModeResolved = false;
}; };
using VideoOutputBackendCreateFn = std::unique_ptr<IVideoOutputEdge> (*)(); using VideoOutputBackendCreateFn = std::unique_ptr<IVideoOutputEdge> (*)(const AppConfig&);
using VideoInputBackendStartFn = std::unique_ptr<VideoInputBackendSession> (*)(const VideoInputBackendStartContext&); using VideoInputBackendStartFn = std::unique_ptr<VideoInputBackendSession> (*)(const VideoInputBackendStartContext&);
struct VideoOutputBackendRegistration struct VideoOutputBackendRegistration
@@ -272,16 +273,21 @@ private:
bool mStarted = false; bool mStarted = false;
}; };
std::unique_ptr<IVideoOutputEdge> CreateDeckLinkOutputBackend() std::unique_ptr<IVideoOutputEdge> CreateDeckLinkOutputBackend(const AppConfig&)
{ {
return std::make_unique<DeckLinkOutput>(); return std::make_unique<DeckLinkOutput>();
} }
std::unique_ptr<IVideoOutputEdge> CreateDisabledOutputBackend() std::unique_ptr<IVideoOutputEdge> CreateDisabledOutputBackend(const AppConfig&)
{ {
return std::make_unique<DisabledVideoOutputEdge>("Video output backend is disabled by config."); return std::make_unique<DisabledVideoOutputEdge>("Video output backend is disabled by config.");
} }
std::unique_ptr<IVideoOutputEdge> CreateNdiOutputBackend(const AppConfig& config)
{
return std::make_unique<NdiOutput>(config.output.device);
}
std::unique_ptr<VideoInputBackendSession> StartNoInputBackend(const VideoInputBackendStartContext&) std::unique_ptr<VideoInputBackendSession> StartNoInputBackend(const VideoInputBackendStartContext&)
{ {
Log("app", "Video input backend disabled by config."); Log("app", "Video input backend disabled by config.");
@@ -322,6 +328,7 @@ const std::vector<VideoOutputBackendRegistration>& VideoOutputBackendRegistry()
{ {
static const std::vector<VideoOutputBackendRegistration> registry = { static const std::vector<VideoOutputBackendRegistration> registry = {
VideoOutputBackendRegistration{ { "decklink" }, true, &CreateDeckLinkOutputBackend }, VideoOutputBackendRegistration{ { "decklink" }, true, &CreateDeckLinkOutputBackend },
VideoOutputBackendRegistration{ { "ndi" }, false, &CreateNdiOutputBackend },
VideoOutputBackendRegistration{ { "none", "disabled", "off" }, false, &CreateDisabledOutputBackend }, VideoOutputBackendRegistration{ { "none", "disabled", "off" }, false, &CreateDisabledOutputBackend },
}; };
return registry; return registry;
@@ -372,7 +379,7 @@ std::unique_ptr<IVideoOutputEdge> CreateVideoOutputBackend(const AppConfig& conf
const std::string backendName = NormalizeBackendName(config.output.backend); const std::string backendName = NormalizeBackendName(config.output.backend);
const VideoOutputBackendRegistration* backend = FindVideoOutputBackend(backendName); const VideoOutputBackendRegistration* backend = FindVideoOutputBackend(backendName);
if (backend != nullptr && backend->create != nullptr) if (backend != nullptr && backend->create != nullptr)
return backend->create(); return backend->create(config);
return std::make_unique<DisabledVideoOutputEdge>("Unsupported output backend: " + config.output.backend); return std::make_unique<DisabledVideoOutputEdge>("Unsupported output backend: " + config.output.backend);
} }

View File

@@ -69,6 +69,7 @@ public:
virtual bool Initialize(const VideoOutputEdgeConfig& config, CompletionCallback completionCallback, std::string& error) = 0; virtual bool Initialize(const VideoOutputEdgeConfig& config, CompletionCallback completionCallback, std::string& error) = 0;
virtual bool StartScheduledPlayback(std::string& error) = 0; virtual bool StartScheduledPlayback(std::string& error) = 0;
virtual bool ScheduleFrame(const VideoIOOutputFrame& frame) = 0; virtual bool ScheduleFrame(const VideoIOOutputFrame& frame) = 0;
virtual bool RequiresPreroll() const { return true; }
virtual void Stop() = 0; virtual void Stop() = 0;
virtual void ReleaseResources() = 0; virtual void ReleaseResources() = 0;
virtual const VideoIOState& State() const = 0; virtual const VideoIOState& State() const = 0;

View File

@@ -1,11 +1,12 @@
#include "NdiInput.h" #include "NdiInput.h"
#include "NdiInputFormat.h"
#include "NdiRuntime.h"
#include "logging/Logger.h" #include "logging/Logger.h"
#include "video/core/VideoIOFormat.h" #include "video/core/VideoIOFormat.h"
#include <Processing.NDI.Lib.h> #include <Processing.NDI.Lib.h>
#include <algorithm>
#include <chrono> #include <chrono>
#include <cstring> #include <cstring>
#include <mutex> #include <mutex>
@@ -17,59 +18,11 @@ namespace
constexpr std::chrono::milliseconds kDiscoveryRetry(500); constexpr std::chrono::milliseconds kDiscoveryRetry(500);
constexpr uint32_t kCaptureTimeoutMilliseconds = 250; constexpr uint32_t kCaptureTimeoutMilliseconds = 250;
std::mutex gNdiRuntimeMutex;
unsigned gNdiRuntimeUsers = 0;
bool AcquireNdiRuntime()
{
std::lock_guard<std::mutex> lock(gNdiRuntimeMutex);
if (gNdiRuntimeUsers == 0 && !NDIlib_initialize())
return false;
++gNdiRuntimeUsers;
return true;
}
void ReleaseNdiRuntime()
{
std::lock_guard<std::mutex> lock(gNdiRuntimeMutex);
if (gNdiRuntimeUsers == 0)
return;
--gNdiRuntimeUsers;
if (gNdiRuntimeUsers == 0)
NDIlib_destroy();
}
bool IsDefaultDeviceName(const std::string& device) bool IsDefaultDeviceName(const std::string& device)
{ {
return device.empty() || device == "default" || device == "auto"; return device.empty() || device == "default" || device == "auto";
} }
std::string FourCcName(NDIlib_FourCC_video_type_e fourCc)
{
char text[5] = {};
const auto value = static_cast<uint32_t>(fourCc);
text[0] = static_cast<char>(value & 0xff);
text[1] = static_cast<char>((value >> 8) & 0xff);
text[2] = static_cast<char>((value >> 16) & 0xff);
text[3] = static_cast<char>((value >> 24) & 0xff);
return text;
}
bool MapNdiPixelFormat(NDIlib_FourCC_video_type_e fourCc, VideoIOPixelFormat& pixelFormat)
{
switch (fourCc)
{
case NDIlib_FourCC_video_type_BGRA:
case NDIlib_FourCC_video_type_BGRX:
pixelFormat = VideoIOPixelFormat::Bgra8;
return true;
case NDIlib_FourCC_video_type_UYVY:
pixelFormat = VideoIOPixelFormat::Uyvy8;
return true;
default:
return false;
}
}
} }
NdiInput::NdiInput(InputFrameMailbox& mailbox) : NdiInput::NdiInput(InputFrameMailbox& mailbox) :
@@ -262,12 +215,12 @@ void NdiInput::HandleVideoFrame(void*, const void* frame)
} }
VideoIOPixelFormat pixelFormat = VideoIOPixelFormat::Bgra8; VideoIOPixelFormat pixelFormat = VideoIOPixelFormat::Bgra8;
if (!MapNdiPixelFormat(videoFrame.FourCC, pixelFormat)) if (!MapNdiFourCcToVideoIOPixelFormat(videoFrame.FourCC, pixelFormat))
{ {
mUnsupportedFrames.fetch_add(1, std::memory_order_relaxed); mUnsupportedFrames.fetch_add(1, std::memory_order_relaxed);
bool expected = false; bool expected = false;
if (mLoggedUnsupportedFrame.compare_exchange_strong(expected, true, std::memory_order_relaxed)) if (mLoggedUnsupportedFrame.compare_exchange_strong(expected, true, std::memory_order_relaxed))
TryLog(LogLevel::Warning, "ndi-input", "Unsupported NDI input frame format " + FourCcName(videoFrame.FourCC) + "."); TryLog(LogLevel::Warning, "ndi-input", std::string("Unsupported NDI input frame format ") + NdiFourCcName(videoFrame.FourCC) + ".");
return; return;
} }

View File

@@ -0,0 +1,35 @@
#include "NdiInputFormat.h"
#include <array>
#include <cstdint>
namespace RenderCadenceCompositor
{
const char* NdiFourCcName(NDIlib_FourCC_video_type_e fourCc)
{
static thread_local std::array<char, 5> text = {};
const auto value = static_cast<uint32_t>(fourCc);
text[0] = static_cast<char>(value & 0xff);
text[1] = static_cast<char>((value >> 8) & 0xff);
text[2] = static_cast<char>((value >> 16) & 0xff);
text[3] = static_cast<char>((value >> 24) & 0xff);
text[4] = '\0';
return text.data();
}
bool MapNdiFourCcToVideoIOPixelFormat(NDIlib_FourCC_video_type_e fourCc, VideoIOPixelFormat& pixelFormat)
{
switch (fourCc)
{
case NDIlib_FourCC_video_type_BGRA:
case NDIlib_FourCC_video_type_BGRX:
pixelFormat = VideoIOPixelFormat::Bgra8;
return true;
case NDIlib_FourCC_video_type_UYVY:
pixelFormat = VideoIOPixelFormat::Uyvy8;
return true;
default:
return false;
}
}
}

View File

@@ -0,0 +1,11 @@
#pragma once
#include "video/core/VideoIOFormat.h"
#include <Processing.NDI.Lib.h>
namespace RenderCadenceCompositor
{
const char* NdiFourCcName(NDIlib_FourCC_video_type_e fourCc);
bool MapNdiFourCcToVideoIOPixelFormat(NDIlib_FourCC_video_type_e fourCc, VideoIOPixelFormat& pixelFormat);
}

253
src/video/ndi/NdiOutput.cpp Normal file
View File

@@ -0,0 +1,253 @@
#include "NdiOutput.h"
#include "NdiRuntime.h"
#include "logging/Logger.h"
#include "video/core/VideoIOFormat.h"
#include <Processing.NDI.Lib.h>
#include <chrono>
#include <cstring>
#include <utility>
namespace RenderCadenceCompositor
{
namespace
{
std::string ResolveSenderName(const std::string& configuredName)
{
if (configuredName.empty() || configuredName == "default" || configuredName == "auto")
return "Render Cadence";
return configuredName;
}
void SetCommonVideoFrameFields(NDIlib_video_frame_v2_t& ndiFrame, const VideoIOOutputFrame& frame, const VideoOutputEdgeConfig& config)
{
std::memset(&ndiFrame, 0, sizeof(ndiFrame));
ndiFrame.xres = static_cast<int>(frame.width);
ndiFrame.yres = static_cast<int>(frame.height);
ndiFrame.FourCC = NDIlib_FourCC_video_type_BGRA;
ndiFrame.frame_rate_N = 60000;
ndiFrame.frame_rate_D = 1001;
if (config.outputVideoMode.frameRate == "60")
{
ndiFrame.frame_rate_N = 60;
ndiFrame.frame_rate_D = 1;
}
else if (config.outputVideoMode.frameRate == "50")
{
ndiFrame.frame_rate_N = 50;
ndiFrame.frame_rate_D = 1;
}
else if (config.outputVideoMode.frameRate == "30")
{
ndiFrame.frame_rate_N = 30;
ndiFrame.frame_rate_D = 1;
}
else if (config.outputVideoMode.frameRate == "29.97")
{
ndiFrame.frame_rate_N = 30000;
ndiFrame.frame_rate_D = 1001;
}
else if (config.outputVideoMode.frameRate == "25")
{
ndiFrame.frame_rate_N = 25;
ndiFrame.frame_rate_D = 1;
}
else if (config.outputVideoMode.frameRate == "24")
{
ndiFrame.frame_rate_N = 24;
ndiFrame.frame_rate_D = 1;
}
else if (config.outputVideoMode.frameRate == "23.98")
{
ndiFrame.frame_rate_N = 24000;
ndiFrame.frame_rate_D = 1001;
}
ndiFrame.picture_aspect_ratio = frame.height > 0 ? static_cast<float>(frame.width) / static_cast<float>(frame.height) : 0.0f;
ndiFrame.frame_format_type = NDIlib_frame_format_type_progressive;
ndiFrame.timecode = NDIlib_send_timecode_synthesize;
ndiFrame.p_data = static_cast<uint8_t*>(frame.bytes);
ndiFrame.line_stride_in_bytes = static_cast<int>(frame.rowBytes);
}
}
NdiOutput::NdiOutput(std::string senderName) :
mSenderName(ResolveSenderName(senderName))
{
}
NdiOutput::~NdiOutput()
{
ReleaseResources();
}
bool NdiOutput::Initialize(const VideoOutputEdgeConfig& config, CompletionCallback completionCallback, std::string& error)
{
ReleaseResources();
mConfig = config;
mCompletionCallback = std::move(completionCallback);
PopulateState(mState, config, mSenderName);
if (config.outputAlphaRequired)
{
mState.statusMessage = "NDI output sends BGRA frames with alpha when present; output.keying.alphaRequired is a DeckLink-only requirement.";
}
if (!AcquireNdiRuntime())
{
error = "NDI runtime initialization failed. The CPU/runtime might not support NDI.";
return false;
}
NDIlib_send_create_t createSettings;
std::memset(&createSettings, 0, sizeof(createSettings));
createSettings.p_ndi_name = mSenderName.c_str();
createSettings.clock_video = false;
createSettings.clock_audio = false;
mSender = NDIlib_send_create(&createSettings);
if (mSender == nullptr)
{
ReleaseNdiRuntime();
error = "NDI sender creation failed.";
return false;
}
mInitialized.store(true, std::memory_order_release);
mState.statusMessage = "NDI output sender '" + mSenderName + "' initialized.";
Log("ndi-output", mState.statusMessage);
return true;
}
bool NdiOutput::StartScheduledPlayback(std::string& error)
{
if (!mInitialized.load(std::memory_order_acquire) || mSender == nullptr)
{
error = "NDI output has not been initialized.";
return false;
}
mRunning.store(true, std::memory_order_release);
Log("ndi-output", "NDI output sender started.");
return true;
}
bool NdiOutput::ScheduleFrame(const VideoIOOutputFrame& frame)
{
if (!mRunning.load(std::memory_order_acquire) || frame.bytes == nullptr || frame.pixelFormat != VideoIOPixelFormat::Bgra8)
{
++mScheduleFailures;
return false;
}
NDIlib_video_frame_v2_t ndiFrame;
SetCommonVideoFrameFields(ndiFrame, frame, mConfig);
std::lock_guard<std::mutex> lock(mMutex);
if (mSender == nullptr)
{
++mScheduleFailures;
return false;
}
const auto scheduleStart = std::chrono::steady_clock::now();
NDIlib_send_send_video_async_v2(static_cast<NDIlib_send_instance_t>(mSender), &ndiFrame);
mScheduleCallMilliseconds.store(
std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(std::chrono::steady_clock::now() - scheduleStart).count(),
std::memory_order_relaxed);
CompletePendingLocked(VideoIOCompletionResult::Completed);
mPendingBuffer = frame.nativeBuffer != nullptr ? frame.nativeBuffer : frame.bytes;
return true;
}
void NdiOutput::Stop()
{
std::lock_guard<std::mutex> lock(mMutex);
if (mSender != nullptr)
FlushPendingLocked();
mRunning.store(false, std::memory_order_release);
}
void NdiOutput::ReleaseResources()
{
{
std::lock_guard<std::mutex> lock(mMutex);
if (mSender != nullptr)
{
FlushPendingLocked();
NDIlib_send_destroy(static_cast<NDIlib_send_instance_t>(mSender));
mSender = nullptr;
ReleaseNdiRuntime();
}
}
mInitialized.store(false, std::memory_order_release);
mRunning.store(false, std::memory_order_release);
}
NdiOutputMetrics NdiOutput::Metrics() const
{
NdiOutputMetrics metrics;
metrics.completions = mCompletions.load(std::memory_order_relaxed);
metrics.dropped = mDropped.load(std::memory_order_relaxed);
metrics.flushed = mFlushed.load(std::memory_order_relaxed);
metrics.scheduleFailures = mScheduleFailures.load(std::memory_order_relaxed);
metrics.scheduleCallMilliseconds = mScheduleCallMilliseconds.load(std::memory_order_relaxed);
metrics.actualBufferedFramesAvailable = false;
{
std::lock_guard<std::mutex> lock(mMutex);
metrics.actualBufferedFrames = mPendingBuffer != nullptr ? 1 : 0;
}
return metrics;
}
void NdiOutput::CompletePendingLocked(VideoIOCompletionResult result)
{
if (mPendingBuffer == nullptr)
return;
VideoIOCompletion completion;
completion.result = result;
completion.outputFrameBuffer = mPendingBuffer;
mPendingBuffer = nullptr;
++mCompletions;
if (result == VideoIOCompletionResult::Dropped)
++mDropped;
else if (result == VideoIOCompletionResult::Flushed)
++mFlushed;
if (mCompletionCallback)
mCompletionCallback(completion);
}
void NdiOutput::FlushPendingLocked()
{
if (mSender != nullptr)
NDIlib_send_send_video_async_v2(static_cast<NDIlib_send_instance_t>(mSender), nullptr);
CompletePendingLocked(VideoIOCompletionResult::Flushed);
}
void NdiOutput::PopulateState(VideoIOState& state, const VideoOutputEdgeConfig& config, const std::string& senderName)
{
state = VideoIOState();
state.outputFrameSize = config.outputVideoMode.frameSize;
state.inputFrameSize = state.outputFrameSize;
state.outputPixelFormat = VideoIOPixelFormat::Bgra8;
state.inputPixelFormat = VideoIOPixelFormat::Bgra8;
state.outputFrameRowBytes = VideoIORowBytes(state.outputPixelFormat, state.outputFrameSize.width);
state.inputFrameRowBytes = state.outputFrameRowBytes;
state.outputPackTextureWidth = state.outputFrameSize.width;
state.captureTextureWidth = state.inputFrameSize.width;
state.outputDisplayModeName = config.outputVideoMode.displayName;
state.inputDisplayModeName = "No input - NDI output session";
state.outputModelName = "NDI: " + senderName;
state.hasInputDevice = false;
state.hasInputSource = false;
state.supportsExternalKeying = false;
state.supportsInternalKeying = false;
state.keyerInterfaceAvailable = false;
state.externalKeyingActive = false;
state.frameBudgetMilliseconds = config.outputVideoMode.frameDurationMilliseconds;
state.formatStatusMessage = "NDI output format: BGRA8.";
}
}

52
src/video/ndi/NdiOutput.h Normal file
View File

@@ -0,0 +1,52 @@
#pragma once
#include "video/core/VideoIOEdges.h"
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
namespace RenderCadenceCompositor
{
using NdiOutputMetrics = VideoOutputEdgeMetrics;
class NdiOutput final : public IVideoOutputEdge
{
public:
explicit NdiOutput(std::string senderName);
NdiOutput(const NdiOutput&) = delete;
NdiOutput& operator=(const NdiOutput&) = delete;
~NdiOutput() override;
bool Initialize(const VideoOutputEdgeConfig& config, CompletionCallback completionCallback, std::string& error) override;
bool StartScheduledPlayback(std::string& error) override;
bool ScheduleFrame(const VideoIOOutputFrame& frame) override;
bool RequiresPreroll() const override { return false; }
void Stop() override;
void ReleaseResources() override;
const VideoIOState& State() const override { return mState; }
NdiOutputMetrics Metrics() const override;
private:
void CompletePendingLocked(VideoIOCompletionResult result);
void FlushPendingLocked();
static void PopulateState(VideoIOState& state, const VideoOutputEdgeConfig& config, const std::string& senderName);
std::string mSenderName;
CompletionCallback mCompletionCallback;
mutable std::mutex mMutex;
void* mSender = nullptr;
void* mPendingBuffer = nullptr;
VideoOutputEdgeConfig mConfig;
VideoIOState mState;
std::atomic<bool> mInitialized{ false };
std::atomic<bool> mRunning{ false };
std::atomic<uint64_t> mCompletions{ 0 };
std::atomic<uint64_t> mDropped{ 0 };
std::atomic<uint64_t> mFlushed{ 0 };
std::atomic<uint64_t> mScheduleFailures{ 0 };
std::atomic<double> mScheduleCallMilliseconds{ 0.0 };
};
}

View File

@@ -0,0 +1,33 @@
#include "NdiRuntime.h"
#include <Processing.NDI.Lib.h>
#include <mutex>
namespace RenderCadenceCompositor
{
namespace
{
std::mutex gNdiRuntimeMutex;
unsigned gNdiRuntimeUsers = 0;
}
bool AcquireNdiRuntime()
{
std::lock_guard<std::mutex> lock(gNdiRuntimeMutex);
if (gNdiRuntimeUsers == 0 && !NDIlib_initialize())
return false;
++gNdiRuntimeUsers;
return true;
}
void ReleaseNdiRuntime()
{
std::lock_guard<std::mutex> lock(gNdiRuntimeMutex);
if (gNdiRuntimeUsers == 0)
return;
--gNdiRuntimeUsers;
if (gNdiRuntimeUsers == 0)
NDIlib_destroy();
}
}

View File

@@ -0,0 +1,7 @@
#pragma once
namespace RenderCadenceCompositor
{
bool AcquireNdiRuntime();
void ReleaseNdiRuntime();
}

View File

@@ -136,6 +136,13 @@ add_video_shader_test(VideoIOFormatTests
"${TEST_DIR}/VideoIOFormatTests.cpp" "${TEST_DIR}/VideoIOFormatTests.cpp"
) )
add_video_shader_test(NdiInputFormatTests
"${SRC_DIR}/video/ndi/NdiInputFormat.cpp"
${VIDEO_FORMAT_SOURCES}
"${TEST_DIR}/NdiInputFormatTests.cpp"
)
target_include_directories(NdiInputFormatTests PRIVATE "${NDI_INCLUDE_DIR}")
add_video_shader_test(VideoPlayoutSchedulerTests add_video_shader_test(VideoPlayoutSchedulerTests
"${SRC_DIR}/video/playout/VideoPlayoutScheduler.cpp" "${SRC_DIR}/video/playout/VideoPlayoutScheduler.cpp"
"${TEST_DIR}/VideoPlayoutSchedulerTests.cpp" "${TEST_DIR}/VideoPlayoutSchedulerTests.cpp"

View File

@@ -0,0 +1,62 @@
#include "video/ndi/NdiInputFormat.h"
#include <iostream>
#include <string>
namespace
{
int gFailures = 0;
void Expect(bool condition, const std::string& message)
{
if (condition)
return;
++gFailures;
std::cerr << "FAILED: " << message << "\n";
}
void TestNdiFourCcMapping()
{
using namespace RenderCadenceCompositor;
VideoIOPixelFormat format = VideoIOPixelFormat::Uyvy8;
Expect(MapNdiFourCcToVideoIOPixelFormat(NDIlib_FourCC_video_type_BGRA, format), "BGRA maps successfully");
Expect(format == VideoIOPixelFormat::Bgra8, "BGRA maps to Bgra8");
format = VideoIOPixelFormat::Uyvy8;
Expect(MapNdiFourCcToVideoIOPixelFormat(NDIlib_FourCC_video_type_BGRX, format), "BGRX maps successfully");
Expect(format == VideoIOPixelFormat::Bgra8, "BGRX maps to Bgra8");
format = VideoIOPixelFormat::Bgra8;
Expect(MapNdiFourCcToVideoIOPixelFormat(NDIlib_FourCC_video_type_UYVY, format), "UYVY maps successfully");
Expect(format == VideoIOPixelFormat::Uyvy8, "UYVY maps to Uyvy8");
format = VideoIOPixelFormat::Bgra8;
Expect(!MapNdiFourCcToVideoIOPixelFormat(NDIlib_FourCC_video_type_RGBA, format), "RGBA is unsupported until conversion is implemented");
Expect(format == VideoIOPixelFormat::Bgra8, "unsupported mapping leaves output untouched");
}
void TestNdiFourCcNames()
{
using namespace RenderCadenceCompositor;
Expect(std::string(NdiFourCcName(NDIlib_FourCC_video_type_BGRA)) == "BGRA", "BGRA name is readable");
Expect(std::string(NdiFourCcName(NDIlib_FourCC_video_type_UYVY)) == "UYVY", "UYVY name is readable");
}
}
int main()
{
TestNdiFourCcMapping();
TestNdiFourCcNames();
if (gFailures != 0)
{
std::cerr << gFailures << " NdiInputFormat test failure(s).\n";
return 1;
}
std::cout << "NdiInputFormat tests passed.\n";
return 0;
}