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

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

View File

@@ -1,6 +1,7 @@
#include "NdiInput.h"
#include "NdiInputFormat.h"
#include "NdiRuntime.h"
#include "logging/Logger.h"
#include "video/core/VideoIOFormat.h"
@@ -17,28 +18,6 @@ namespace
constexpr std::chrono::milliseconds kDiscoveryRetry(500);
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)
{
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();
}