From 93aa8fa9815f4fac59cd09fa10f1a863fe906b79 Mon Sep 17 00:00:00 2001 From: Aiden Date: Fri, 22 May 2026 16:41:24 +1000 Subject: [PATCH] NDI output --- config/runtime-host.json | 6 +- config/runtime-host.schema.json | 7 +- src/app/RenderCadenceApp.h | 47 ++++-- src/app/VideoBackendFactory.cpp | 15 +- src/video/core/VideoIOEdges.h | 1 + src/video/ndi/NdiInput.cpp | 23 +-- src/video/ndi/NdiOutput.cpp | 253 ++++++++++++++++++++++++++++++++ src/video/ndi/NdiOutput.h | 52 +++++++ src/video/ndi/NdiRuntime.cpp | 33 +++++ src/video/ndi/NdiRuntime.h | 7 + 10 files changed, 397 insertions(+), 47 deletions(-) create mode 100644 src/video/ndi/NdiOutput.cpp create mode 100644 src/video/ndi/NdiOutput.h create mode 100644 src/video/ndi/NdiRuntime.cpp create mode 100644 src/video/ndi/NdiRuntime.h diff --git a/config/runtime-host.json b/config/runtime-host.json index 1e35151..ca26f3d 100644 --- a/config/runtime-host.json +++ b/config/runtime-host.json @@ -6,14 +6,14 @@ "oscPort": 9000, "oscSmoothing": 0.18, "input": { - "backend": "ndi", + "backend": "none", "device": "AIDENLAPTOP (NVIDIA GeForce RTX 4090 Laptop GPU 1)", "resolution": "1080p", "frameRate": "59.94" }, "output": { - "backend": "decklink", - "device": "default", + "backend": "ndi", + "device": "shader toys", "resolution": "1080p", "frameRate": "59.94", "keying": { diff --git a/config/runtime-host.schema.json b/config/runtime-host.schema.json index 434c361..c235f1e 100644 --- a/config/runtime-host.schema.json +++ b/config/runtime-host.schema.json @@ -90,6 +90,7 @@ "type": "string", "enum": [ "decklink", + "ndi", "none", "disabled", "off" @@ -155,17 +156,17 @@ "output": { "type": "object", "additionalProperties": false, - "description": "Video output backend configuration. NDI output is not implemented yet; use DeckLink or disable output.", + "description": "Video output backend configuration.", "properties": { "backend": { "$ref": "#/$defs/outputBackend", "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": { "type": "string", "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": { "$ref": "#/$defs/resolution", diff --git a/src/app/RenderCadenceApp.h b/src/app/RenderCadenceApp.h index e5bfafc..9cdf891 100644 --- a/src/app/RenderCadenceApp.h +++ b/src/app/RenderCadenceApp.h @@ -162,25 +162,42 @@ private: return; } - Log("app", "Starting video output thread."); - if (!mOutputThread.Start()) + if (mOutput->RequiresPreroll()) { - DisableVideoOutput("Video output thread failed to start."); - return; - } + Log("app", "Starting video output thread."); + if (!mOutputThread.Start()) + { + DisableVideoOutput("Video output thread failed to start."); + return; + } - Log("app", "Waiting for video output preroll frames."); - if (!WaitForPreroll()) - { - DisableVideoOutput("Timed out waiting for DeckLink preroll frames."); - return; - } + Log("app", "Waiting for video output preroll frames."); + if (!WaitForPreroll()) + { + DisableVideoOutput("Timed out waiting for video output preroll frames."); + return; + } - Log("app", "Starting scheduled video playback."); - if (!mOutput->StartScheduledPlayback(outputError)) + Log("app", "Starting scheduled video playback."); + if (!mOutput->StartScheduledPlayback(outputError)) + { + DisableVideoOutput("Scheduled video playback failed: " + outputError); + return; + } + } + else { - DisableVideoOutput("Scheduled video playback failed: " + outputError); - return; + 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; diff --git a/src/app/VideoBackendFactory.cpp b/src/app/VideoBackendFactory.cpp index 98da13b..7645875 100644 --- a/src/app/VideoBackendFactory.cpp +++ b/src/app/VideoBackendFactory.cpp @@ -6,6 +6,7 @@ #include "video/decklink/DeckLinkInputThread.h" #include "video/decklink/DeckLinkOutput.h" #include "video/ndi/NdiInput.h" +#include "video/ndi/NdiOutput.h" #include #include @@ -58,7 +59,7 @@ struct VideoInputBackendStartContext bool inputVideoModeResolved = false; }; -using VideoOutputBackendCreateFn = std::unique_ptr (*)(); +using VideoOutputBackendCreateFn = std::unique_ptr (*)(const AppConfig&); using VideoInputBackendStartFn = std::unique_ptr (*)(const VideoInputBackendStartContext&); struct VideoOutputBackendRegistration @@ -272,16 +273,21 @@ private: bool mStarted = false; }; -std::unique_ptr CreateDeckLinkOutputBackend() +std::unique_ptr CreateDeckLinkOutputBackend(const AppConfig&) { return std::make_unique(); } -std::unique_ptr CreateDisabledOutputBackend() +std::unique_ptr CreateDisabledOutputBackend(const AppConfig&) { return std::make_unique("Video output backend is disabled by config."); } +std::unique_ptr CreateNdiOutputBackend(const AppConfig& config) +{ + return std::make_unique(config.output.device); +} + std::unique_ptr StartNoInputBackend(const VideoInputBackendStartContext&) { Log("app", "Video input backend disabled by config."); @@ -322,6 +328,7 @@ const std::vector& VideoOutputBackendRegistry() { static const std::vector registry = { VideoOutputBackendRegistration{ { "decklink" }, true, &CreateDeckLinkOutputBackend }, + VideoOutputBackendRegistration{ { "ndi" }, false, &CreateNdiOutputBackend }, VideoOutputBackendRegistration{ { "none", "disabled", "off" }, false, &CreateDisabledOutputBackend }, }; return registry; @@ -372,7 +379,7 @@ std::unique_ptr CreateVideoOutputBackend(const AppConfig& conf const std::string backendName = NormalizeBackendName(config.output.backend); const VideoOutputBackendRegistration* backend = FindVideoOutputBackend(backendName); if (backend != nullptr && backend->create != nullptr) - return backend->create(); + return backend->create(config); return std::make_unique("Unsupported output backend: " + config.output.backend); } diff --git a/src/video/core/VideoIOEdges.h b/src/video/core/VideoIOEdges.h index b89d684..d66ff61 100644 --- a/src/video/core/VideoIOEdges.h +++ b/src/video/core/VideoIOEdges.h @@ -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; diff --git a/src/video/ndi/NdiInput.cpp b/src/video/ndi/NdiInput.cpp index 52e6075..da33697 100644 --- a/src/video/ndi/NdiInput.cpp +++ b/src/video/ndi/NdiInput.cpp @@ -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 lock(gNdiRuntimeMutex); - if (gNdiRuntimeUsers == 0 && !NDIlib_initialize()) - return false; - ++gNdiRuntimeUsers; - return true; -} - -void ReleaseNdiRuntime() -{ - std::lock_guard 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"; diff --git a/src/video/ndi/NdiOutput.cpp b/src/video/ndi/NdiOutput.cpp new file mode 100644 index 0000000..386da63 --- /dev/null +++ b/src/video/ndi/NdiOutput.cpp @@ -0,0 +1,253 @@ +#include "NdiOutput.h" + +#include "NdiRuntime.h" +#include "logging/Logger.h" +#include "video/core/VideoIOFormat.h" + +#include + +#include +#include +#include + +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(frame.width); + ndiFrame.yres = static_cast(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(frame.width) / static_cast(frame.height) : 0.0f; + ndiFrame.frame_format_type = NDIlib_frame_format_type_progressive; + ndiFrame.timecode = NDIlib_send_timecode_synthesize; + ndiFrame.p_data = static_cast(frame.bytes); + ndiFrame.line_stride_in_bytes = static_cast(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 lock(mMutex); + if (mSender == nullptr) + { + ++mScheduleFailures; + return false; + } + + const auto scheduleStart = std::chrono::steady_clock::now(); + NDIlib_send_send_video_async_v2(static_cast(mSender), &ndiFrame); + mScheduleCallMilliseconds.store( + std::chrono::duration_cast>(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 lock(mMutex); + if (mSender != nullptr) + FlushPendingLocked(); + mRunning.store(false, std::memory_order_release); +} + +void NdiOutput::ReleaseResources() +{ + { + std::lock_guard lock(mMutex); + if (mSender != nullptr) + { + FlushPendingLocked(); + NDIlib_send_destroy(static_cast(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 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(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."; +} +} diff --git a/src/video/ndi/NdiOutput.h b/src/video/ndi/NdiOutput.h new file mode 100644 index 0000000..8baddb2 --- /dev/null +++ b/src/video/ndi/NdiOutput.h @@ -0,0 +1,52 @@ +#pragma once + +#include "video/core/VideoIOEdges.h" + +#include +#include +#include +#include + +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 mInitialized{ false }; + std::atomic mRunning{ false }; + std::atomic mCompletions{ 0 }; + std::atomic mDropped{ 0 }; + std::atomic mFlushed{ 0 }; + std::atomic mScheduleFailures{ 0 }; + std::atomic mScheduleCallMilliseconds{ 0.0 }; +}; +} diff --git a/src/video/ndi/NdiRuntime.cpp b/src/video/ndi/NdiRuntime.cpp new file mode 100644 index 0000000..457e2af --- /dev/null +++ b/src/video/ndi/NdiRuntime.cpp @@ -0,0 +1,33 @@ +#include "NdiRuntime.h" + +#include + +#include + +namespace RenderCadenceCompositor +{ +namespace +{ +std::mutex gNdiRuntimeMutex; +unsigned gNdiRuntimeUsers = 0; +} + +bool AcquireNdiRuntime() +{ + std::lock_guard lock(gNdiRuntimeMutex); + if (gNdiRuntimeUsers == 0 && !NDIlib_initialize()) + return false; + ++gNdiRuntimeUsers; + return true; +} + +void ReleaseNdiRuntime() +{ + std::lock_guard lock(gNdiRuntimeMutex); + if (gNdiRuntimeUsers == 0) + return; + --gNdiRuntimeUsers; + if (gNdiRuntimeUsers == 0) + NDIlib_destroy(); +} +} diff --git a/src/video/ndi/NdiRuntime.h b/src/video/ndi/NdiRuntime.h new file mode 100644 index 0000000..fb25bae --- /dev/null +++ b/src/video/ndi/NdiRuntime.h @@ -0,0 +1,7 @@ +#pragma once + +namespace RenderCadenceCompositor +{ +bool AcquireNdiRuntime(); +void ReleaseNdiRuntime(); +}