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

This commit is contained in:
2026-05-22 16:41:24 +10:00
parent 4e6b37304f
commit 93aa8fa981
10 changed files with 397 additions and 47 deletions

View File

@@ -6,14 +6,14 @@
"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

@@ -90,6 +90,7 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"decklink", "decklink",
"ndi",
"none", "none",
"disabled", "disabled",
"off" "off"
@@ -155,17 +156,17 @@
"output": { "output": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"description": "Video output backend configuration. NDI output is not implemented yet; use DeckLink or disable output.", "description": "Video output backend configuration.",
"properties": { "properties": {
"backend": { "backend": {
"$ref": "#/$defs/outputBackend", "$ref": "#/$defs/outputBackend",
"default": "decklink", "default": "decklink",
"description": "Output backend. Currently 'decklink' and disabled aliases are supported. 'ndi' is reserved for future output work and will be rejected as unsupported at runtime today." "description": "Output backend. Use 'decklink' for Blackmagic playout, 'ndi' for NDI send, or 'none'/'disabled'/'off' to disable scheduled output."
}, },
"device": { "device": {
"type": "string", "type": "string",
"default": "default", "default": "default",
"description": "Output device selector. DeckLink currently uses the first compatible/default output device." "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": { "resolution": {
"$ref": "#/$defs/resolution", "$ref": "#/$defs/resolution",

View File

@@ -162,25 +162,42 @@ private:
return; return;
} }
Log("app", "Starting video output thread."); if (mOutput->RequiresPreroll())
if (!mOutputThread.Start())
{ {
DisableVideoOutput("Video output thread failed to start."); Log("app", "Starting video output thread.");
return; if (!mOutputThread.Start())
} {
DisableVideoOutput("Video output thread failed to start.");
return;
}
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;
} }
Log("app", "Starting scheduled video playback."); Log("app", "Starting scheduled video playback.");
if (!mOutput->StartScheduledPlayback(outputError)) if (!mOutput->StartScheduledPlayback(outputError))
{
DisableVideoOutput("Scheduled video playback failed: " + outputError);
return;
}
}
else
{ {
DisableVideoOutput("Scheduled video playback failed: " + outputError); Log("app", "Starting video output backend without preroll.");
return; 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;

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,6 +1,7 @@
#include "NdiInput.h" #include "NdiInput.h"
#include "NdiInputFormat.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"
@@ -17,28 +18,6 @@ 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";

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();
}