2 Commits

Author SHA1 Message Date
80c6fd2434 NDI cleanup to avoid calling NDI while holding the output mutex
Some checks failed
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Failing after 1m46s
CI / Windows Release Package (push) Has been skipped
2026-05-22 16:51:48 +10:00
f8c3c60611 build release check 2026-05-22 16:46:39 +10:00
4 changed files with 76 additions and 40 deletions

View File

@@ -168,7 +168,7 @@ else()
)
endif()
else()
message(STATUS "NDI SDK headers/import library not found; NDI input backend will not build correctly: ${NDI_SDK_ROOT}")
message(STATUS "NDI SDK headers/import library not found; NDI backends will not build correctly: ${NDI_SDK_ROOT}")
endif()
target_link_libraries(RenderCadenceCompositor PRIVATE
opengl32

View File

@@ -1,6 +1,6 @@
# Video Shader
Native video shader host with an OpenGL render path, pluggable video I/O boundary, DeckLink backend, Slang shader packages, and a local React control UI.
Native video shader host with an OpenGL render path, pluggable video I/O boundary, DeckLink and NDI backends, Slang shader packages, and a local React control UI.
The app loads shader packages from `shaders/`, compiles Slang to GLSL at runtime, renders a configurable layer stack, and exposes a browser-based control surface over a local HTTP/WebSocket server. Shader compilation is prepared off the frame path where possible, then committed on the render thread so editing shader files does not block video output for the whole compile.
@@ -24,7 +24,8 @@ Native app internals are grouped by boundary:
- `runtime/`: shader catalog support, layer model, Slang build bridge, font atlas build, and runtime-state persistence.
- `shader/`: shader package parsing and Slang compilation helpers.
- `video/core/`: backend-neutral video IO handoff contracts, mode descriptions, pixel formats, and output scheduling thread.
- `video/decklink/`: current DeckLink input/output backend.
- `video/decklink/`: DeckLink input/output backend.
- `video/ndi/`: NDI input/output backend.
- `video/playout/`: backend-adjacent playout policy, queues, frame pools, and scheduling helpers.
- `video/legacy/`: older backend pipeline pieces kept separate while the new edge model settles.
@@ -34,6 +35,7 @@ Native app internals are grouped by boundary:
- CMake 3.24 or newer.
- Node.js and npm for the control UI.
- Blackmagic Desktop Video drivers and a DeckLink device for the current production video I/O backend.
- NDI 6 SDK for NDI input/output builds.
- Slang binary release with `slangc.exe`, `slang-compiler.dll`, `slang-glslang.dll`, and `LICENSE`.
- `msdf-atlas-gen` Windows binary release with `msdf-atlas-gen.exe`, `LICENSE.txt`, and any adjacent runtime DLLs for font atlas generation.
@@ -49,6 +51,12 @@ Default expected `msdf-atlas-gen` path:
3rdParty/msdf-atlas-gen
```
Default expected NDI SDK path:
```text
3rdParty/NDI 6 SDK
```
Override example:
```powershell
@@ -99,6 +107,7 @@ The package folder will contain:
```text
dist/VideoShader/
RenderCadenceCompositor.exe
Processing.NDI.Lib.x64.dll
config/
shaders/
3rdParty/slang/bin/
@@ -110,9 +119,9 @@ dist/VideoShader/
third_party_notices/
```
You can run `RenderCadenceCompositor.exe` directly from that folder. In packaged mode, the app resolves `config/`, `shaders/`, `3rdParty/slang/bin/slangc.exe`, `3rdParty/msdf-atlas-gen/msdf-atlas-gen.exe`, `ui/dist/`, and `runtime/templates/` relative to the exe folder. In development mode, it still falls back to repo-root discovery.
You can run `RenderCadenceCompositor.exe` directly from that folder. In packaged mode, the app resolves `config/`, `shaders/`, `3rdParty/slang/bin/slangc.exe`, `3rdParty/msdf-atlas-gen/msdf-atlas-gen.exe`, `Processing.NDI.Lib.x64.dll`, `ui/dist/`, and `runtime/templates/` relative to the exe folder. In development mode, it still falls back to repo-root discovery.
The install step copies only the Slang runtime files required by the shader compiler (`slangc.exe`, `slang-compiler.dll`, and `slang-glslang.dll`) plus `third_party_notices/SLANG_LICENSE.txt`. It also copies `msdf-atlas-gen.exe`, any adjacent `msdf-atlas-gen` DLLs, and the `third_party_notices/MSDF_ATLAS_GEN_LICENSE.txt` and `third_party_notices/MSDF_ATLAS_GEN_README.md` notice files. It does not copy full third-party release folders.
The install step copies only the Slang runtime files required by the shader compiler (`slangc.exe`, `slang-compiler.dll`, and `slang-glslang.dll`) plus `third_party_notices/SLANG_LICENSE.txt`. It also copies `msdf-atlas-gen.exe`, any adjacent `msdf-atlas-gen` DLLs, `Processing.NDI.Lib.x64.dll`, and the `third_party_notices/MSDF_ATLAS_GEN_LICENSE.txt`, `third_party_notices/MSDF_ATLAS_GEN_README.md`, `third_party_notices/NDI_SDK_LICENSE_AGREEMENT.pdf`, and `third_party_notices/NDI_RUNTIME_LICENSES.txt` notice files. It does not copy full third-party release folders.
Create a zip for distribution:

View File

@@ -134,7 +134,7 @@ bool NdiOutput::StartScheduledPlayback(std::string& error)
bool NdiOutput::ScheduleFrame(const VideoIOOutputFrame& frame)
{
if (!mRunning.load(std::memory_order_acquire) || frame.bytes == nullptr || frame.pixelFormat != VideoIOPixelFormat::Bgra8)
if (frame.bytes == nullptr || frame.pixelFormat != VideoIOPixelFormat::Bgra8)
{
++mScheduleFailures;
return false;
@@ -143,44 +143,79 @@ bool NdiOutput::ScheduleFrame(const VideoIOOutputFrame& frame)
NDIlib_video_frame_v2_t ndiFrame;
SetCommonVideoFrameFields(ndiFrame, frame, mConfig);
std::lock_guard<std::mutex> lock(mMutex);
if (mSender == nullptr)
void* completedBuffer = nullptr;
{
std::lock_guard<std::mutex> sdkLock(mSdkMutex);
void* sender = nullptr;
{
std::lock_guard<std::mutex> stateLock(mMutex);
if (!mRunning.load(std::memory_order_acquire) || mSender == nullptr)
{
++mScheduleFailures;
return false;
}
sender = mSender;
}
const auto scheduleStart = std::chrono::steady_clock::now();
NDIlib_send_send_video_async_v2(static_cast<NDIlib_send_instance_t>(mSender), &ndiFrame);
NDIlib_send_send_video_async_v2(static_cast<NDIlib_send_instance_t>(sender), &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);
std::lock_guard<std::mutex> stateLock(mMutex);
completedBuffer = mPendingBuffer;
mPendingBuffer = frame.nativeBuffer != nullptr ? frame.nativeBuffer : frame.bytes;
}
CompleteBuffer(completedBuffer, VideoIOCompletionResult::Completed);
return true;
}
void NdiOutput::Stop()
{
std::lock_guard<std::mutex> lock(mMutex);
if (mSender != nullptr)
FlushPendingLocked();
void* flushedBuffer = nullptr;
{
std::lock_guard<std::mutex> sdkLock(mSdkMutex);
void* sender = nullptr;
{
std::lock_guard<std::mutex> stateLock(mMutex);
sender = mSender;
}
if (sender != nullptr)
NDIlib_send_send_video_async_v2(static_cast<NDIlib_send_instance_t>(sender), nullptr);
std::lock_guard<std::mutex> stateLock(mMutex);
flushedBuffer = mPendingBuffer;
mPendingBuffer = nullptr;
mRunning.store(false, std::memory_order_release);
}
CompleteBuffer(flushedBuffer, VideoIOCompletionResult::Flushed);
}
void NdiOutput::ReleaseResources()
{
void* sender = nullptr;
void* flushedBuffer = nullptr;
{
std::lock_guard<std::mutex> lock(mMutex);
if (mSender != nullptr)
std::lock_guard<std::mutex> sdkLock(mSdkMutex);
{
FlushPendingLocked();
NDIlib_send_destroy(static_cast<NDIlib_send_instance_t>(mSender));
std::lock_guard<std::mutex> stateLock(mMutex);
sender = mSender;
mSender = nullptr;
flushedBuffer = mPendingBuffer;
mPendingBuffer = nullptr;
}
if (sender != nullptr)
{
NDIlib_send_send_video_async_v2(static_cast<NDIlib_send_instance_t>(sender), nullptr);
NDIlib_send_destroy(static_cast<NDIlib_send_instance_t>(sender));
}
}
CompleteBuffer(flushedBuffer, VideoIOCompletionResult::Flushed);
if (sender != nullptr)
ReleaseNdiRuntime();
}
}
mInitialized.store(false, std::memory_order_release);
mRunning.store(false, std::memory_order_release);
}
@@ -201,15 +236,14 @@ NdiOutputMetrics NdiOutput::Metrics() const
return metrics;
}
void NdiOutput::CompletePendingLocked(VideoIOCompletionResult result)
void NdiOutput::CompleteBuffer(void* buffer, VideoIOCompletionResult result)
{
if (mPendingBuffer == nullptr)
if (buffer == nullptr)
return;
VideoIOCompletion completion;
completion.result = result;
completion.outputFrameBuffer = mPendingBuffer;
mPendingBuffer = nullptr;
completion.outputFrameBuffer = buffer;
++mCompletions;
if (result == VideoIOCompletionResult::Dropped)
++mDropped;
@@ -220,13 +254,6 @@ void NdiOutput::CompletePendingLocked(VideoIOCompletionResult result)
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();

View File

@@ -30,13 +30,13 @@ public:
NdiOutputMetrics Metrics() const override;
private:
void CompletePendingLocked(VideoIOCompletionResult result);
void FlushPendingLocked();
void CompleteBuffer(void* buffer, VideoIOCompletionResult result);
static void PopulateState(VideoIOState& state, const VideoOutputEdgeConfig& config, const std::string& senderName);
std::string mSenderName;
CompletionCallback mCompletionCallback;
mutable std::mutex mMutex;
mutable std::mutex mSdkMutex;
void* mSender = nullptr;
void* mPendingBuffer = nullptr;
VideoOutputEdgeConfig mConfig;