optional preview frame
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m23s
CI / Windows Release Package (push) Has been skipped

This commit is contained in:
2026-05-20 14:37:24 +10:00
parent 1d4eb7a34c
commit bfaa3f5e0e
25 changed files with 700 additions and 2740 deletions

View File

@@ -33,6 +33,12 @@ DeckLinkOutputThread
consumes completed system-memory frames
schedules them into DeckLink up to target depth
never renders
PreviewWindowThread
optionally owns a Win32/GDI preview window
copies the latest completed or scheduled system-memory frame without consuming it
skips preview ticks instead of waiting for the frame exchange lock
never calls GL, DeckLink, shader build, or render cadence code
```
Startup builds a small output preroll reserve before DeckLink scheduled playback starts. When DeckLink input is available, startup also waits briefly for three ready input frames before the render thread starts so the first render ticks are deliberate rather than lucky.
@@ -74,6 +80,7 @@ Included now:
- trigger parameters as latest-pulse controls with shader-visible count/time
- startup config provider for `config/runtime-host.json`
- quiet telemetry health monitor
- optional preview window fed from completed system-memory frames on its own thread
- non-GL frame-exchange tests
- non-GL input-mailbox tests
@@ -87,7 +94,6 @@ Intentionally not included yet:
- OSC control
- persistent control/state writes
- trigger event history for stacked repeated pulses
- preview
- screenshots
- persistence
@@ -140,7 +146,7 @@ This tracks parity with `apps/LoopThroughWithOpenGLCompositing`.
- [ ] Full runtime state store/read model
- [ ] Persistent layer stack/config writes
- [ ] OSC ingress
- [ ] Preview output
- [x] Preview output from a non-consuming system-memory tap
- [ ] Screenshot capture
- [ ] External keying support
- [ ] Full V1 health/runtime presentation model
@@ -198,9 +204,12 @@ Currently consumed fields:
- `outputFrameRate`
- `autoReload`
- `maxTemporalHistoryFrames`
- `previewEnabled`
- `previewFps`
- `enableExternalKeying`
When `previewEnabled` is true, the preview window runs on `PreviewWindowThread`. It paints BGRA8 system-memory frames with Win32/GDI after render readback has already completed, so it does not bind GL and does not consume frames from DeckLink output.
The loaded config is treated as a read-only startup snapshot. Subsystems that need config should receive this snapshot or a narrowed config struct from app orchestration; they should not reload files independently.
Supported CLI overrides:

View File

@@ -28,7 +28,8 @@ AppConfig DefaultAppConfig()
config.outputFrameRate = "59.94";
config.autoReload = true;
config.maxTemporalHistoryFrames = 12;
config.previewFps = 30.0;
config.previewEnabled = false;
config.previewFps = kDefaultPreviewFps;
config.warmupCompletedFrames = 4;
config.warmupTimeout = std::chrono::seconds(3);
config.prerollTimeout = std::chrono::seconds(3);

View File

@@ -2,6 +2,7 @@
#include "../control/http/HttpControlServer.h"
#include "../logging/Logger.h"
#include "../preview/PreviewConfig.h"
#include "../telemetry/TelemetryHealthMonitor.h"
#include "../video/DeckLinkOutput.h"
#include "../video/DeckLinkOutputThread.h"
@@ -29,7 +30,8 @@ struct AppConfig
std::string outputFrameRate = "59.94";
bool autoReload = true;
std::size_t maxTemporalHistoryFrames = 12;
double previewFps = 30.0;
bool previewEnabled = false;
double previewFps = kDefaultPreviewFps;
std::size_t warmupCompletedFrames = 4;
std::chrono::milliseconds warmupTimeout = std::chrono::seconds(3);
std::chrono::milliseconds prerollTimeout = std::chrono::seconds(3);

View File

@@ -132,6 +132,7 @@ bool AppConfigProvider::Load(const std::filesystem::path& path, std::string& err
ApplyString(root, "outputFrameRate", mConfig.outputFrameRate);
ApplyBool(root, "autoReload", mConfig.autoReload);
ApplySize(root, "maxTemporalHistoryFrames", mConfig.maxTemporalHistoryFrames);
ApplyBool(root, "previewEnabled", mConfig.previewEnabled);
ApplyDouble(root, "previewFps", mConfig.previewFps);
ApplyBool(root, "enableExternalKeying", mConfig.deckLink.externalKeyingEnabled);

View File

@@ -5,6 +5,7 @@
#include "RuntimeLayerController.h"
#include "../logging/Logger.h"
#include "../control/RuntimeStateJson.h"
#include "../preview/PreviewWindowThread.h"
#include "../telemetry/TelemetryHealthMonitor.h"
#include "../video/DeckLinkInput.h"
#include "../video/DeckLinkOutput.h"
@@ -94,6 +95,7 @@ public:
return false;
}
StartPreviewWindow();
StartOptionalVideoOutput();
mTelemetryHealth.Start(mFrameExchange, mOutput, mOutputThread, mRenderThread);
StartHttpServer();
@@ -106,6 +108,7 @@ public:
{
mHttpServer.Stop();
mTelemetryHealth.Stop();
mPreviewWindow.Stop();
mOutputThread.Stop();
mOutput.Stop();
mRuntimeLayers.Stop();
@@ -228,6 +231,24 @@ private:
}
}
void StartPreviewWindow()
{
if (!mConfig.previewEnabled)
return;
PreviewWindowConfig previewConfig;
previewConfig.enabled = true;
previewConfig.fps = mConfig.previewFps;
std::string error;
if (mPreviewWindow.Start(mFrameExchange, previewConfig, error))
{
Log("preview", "Preview window thread started.");
return;
}
LogWarning("preview", "Preview window did not start: " + error);
}
std::string BuildStateJson()
{
CadenceTelemetrySnapshot telemetry = mHttpTelemetry.Sample(mFrameExchange, mOutput, mOutputThread, mRenderThread);
@@ -281,6 +302,7 @@ private:
TelemetryHealthMonitor mTelemetryHealth;
CadenceTelemetry mHttpTelemetry;
HttpControlServer mHttpServer;
PreviewWindowThread mPreviewWindow;
RuntimeLayerController mRuntimeLayers;
std::function<DeckLinkInputMetrics()> mDeckLinkInputMetricsProvider;
uint64_t mLastInputCapturedFrames = 0;

View File

@@ -29,6 +29,7 @@ void SystemFrameExchange::Configure(const SystemFrameExchangeConfig& config)
slot.bytes.resize(byteCount);
slot.state = SystemFrameSlotState::Free;
slot.frameIndex = 0;
slot.previewReaders = 0;
++slot.generation;
}
@@ -110,16 +111,65 @@ bool SystemFrameExchange::ReleaseScheduledByBytes(void* bytes)
if (slot.state != SystemFrameSlotState::Scheduled)
return false;
slot.state = SystemFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
mCondition.notify_all();
ReleaseFreeIfUnreferencedLocked(slot);
return true;
}
return false;
}
bool SystemFrameExchange::TryAcquireLatestForPreview(SystemFrame& frame)
{
std::unique_lock<std::mutex> lock(mMutex, std::try_to_lock);
if (!lock.owns_lock())
return false;
std::size_t bestIndex = mSlots.size();
uint64_t bestFrameIndex = 0;
for (std::size_t index = 0; index < mSlots.size(); ++index)
{
const Slot& slot = mSlots[index];
if (slot.state != SystemFrameSlotState::Completed && slot.state != SystemFrameSlotState::Scheduled)
continue;
if (bestIndex == mSlots.size() || slot.frameIndex >= bestFrameIndex)
{
bestIndex = index;
bestFrameIndex = slot.frameIndex;
}
}
if (bestIndex == mSlots.size())
{
frame = SystemFrame();
return false;
}
Slot& slot = mSlots[bestIndex];
++slot.previewReaders;
FillFrameLocked(bestIndex, frame);
return true;
}
bool SystemFrameExchange::ReleasePreviewFrame(const SystemFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);
if (!IsValidLocked(frame))
return false;
Slot& slot = mSlots[frame.index];
if (slot.previewReaders == 0)
return false;
--slot.previewReaders;
if (slot.previewReaders == 0 && slot.state == SystemFrameSlotState::Free)
{
slot.frameIndex = 0;
++slot.generation;
mCondition.notify_all();
}
return true;
}
bool SystemFrameExchange::WaitForCompletedDepth(std::size_t targetDepth, std::chrono::milliseconds timeout)
{
std::unique_lock<std::mutex> lock(mMutex);
@@ -180,8 +230,11 @@ void SystemFrameExchange::Clear()
for (Slot& slot : mSlots)
{
slot.state = SystemFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
if (slot.previewReaders == 0)
{
slot.frameIndex = 0;
++slot.generation;
}
}
mCondition.notify_all();
}
@@ -198,7 +251,8 @@ SystemFrameExchangeMetrics SystemFrameExchange::Metrics() const
switch (slot.state)
{
case SystemFrameSlotState::Free:
++metrics.freeCount;
if (slot.previewReaders == 0)
++metrics.freeCount;
break;
case SystemFrameSlotState::Rendering:
++metrics.renderingCount;
@@ -222,6 +276,8 @@ bool SystemFrameExchange::AcquireFreeLocked(SystemFrame& frame)
Slot& slot = mSlots[index];
if (slot.state != SystemFrameSlotState::Free)
continue;
if (slot.previewReaders != 0)
continue;
slot.state = SystemFrameSlotState::Rendering;
++slot.generation;
@@ -242,17 +298,26 @@ bool SystemFrameExchange::DropOldestCompletedLocked()
continue;
Slot& slot = mSlots[index];
slot.state = SystemFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
ReleaseFreeIfUnreferencedLocked(slot);
++mCounters.completedDrops;
mCondition.notify_all();
return true;
}
return false;
}
bool SystemFrameExchange::ReleaseFreeIfUnreferencedLocked(Slot& slot)
{
slot.state = SystemFrameSlotState::Free;
if (slot.previewReaders != 0)
return false;
slot.frameIndex = 0;
++slot.generation;
mCondition.notify_all();
return true;
}
void SystemFrameExchange::TrimCompletedLocked()
{
if (mConfig.maxCompletedFrames == 0)

View File

@@ -21,6 +21,8 @@ public:
bool PublishCompleted(const SystemFrame& frame);
bool ConsumeCompletedForSchedule(SystemFrame& frame);
bool ReleaseScheduledByBytes(void* bytes);
bool TryAcquireLatestForPreview(SystemFrame& frame);
bool ReleasePreviewFrame(const SystemFrame& frame);
bool WaitForCompletedDepth(std::size_t targetDepth, std::chrono::milliseconds timeout);
bool WaitForStableCompletedDepth(
std::size_t targetDepth,
@@ -37,10 +39,12 @@ private:
SystemFrameSlotState state = SystemFrameSlotState::Free;
uint64_t generation = 1;
uint64_t frameIndex = 0;
std::size_t previewReaders = 0;
};
bool AcquireFreeLocked(SystemFrame& frame);
bool DropOldestCompletedLocked();
bool ReleaseFreeIfUnreferencedLocked(Slot& slot);
void TrimCompletedLocked();
bool IsValidLocked(const SystemFrame& frame) const;
void FillFrameLocked(std::size_t index, SystemFrame& frame);

View File

@@ -0,0 +1,28 @@
#pragma once
#include <string>
namespace RenderCadenceCompositor
{
constexpr double kDefaultPreviewFps = 30.0;
constexpr double kMinimumPreviewFps = 1.0;
struct PreviewWindowConfig
{
bool enabled = false;
double fps = kDefaultPreviewFps;
std::string title = "Render Cadence Preview";
};
inline double NormalizePreviewFps(double fps)
{
return fps >= kMinimumPreviewFps ? fps : kDefaultPreviewFps;
}
inline unsigned PreviewTimerIntervalMilliseconds(double fps)
{
const double normalizedFps = NormalizePreviewFps(fps);
const int intervalMilliseconds = static_cast<int>(1000.0 / normalizedFps);
return static_cast<unsigned>(intervalMilliseconds > 0 ? intervalMilliseconds : 1);
}
}

View File

@@ -0,0 +1,246 @@
#include "PreviewWindowThread.h"
#include "../frames/SystemFrameExchange.h"
#include "../logging/Logger.h"
#include <algorithm>
namespace RenderCadenceCompositor
{
namespace
{
const char* kPreviewWindowClassName = "RenderCadencePreviewWindow";
constexpr UINT_PTR kPreviewTimerId = 1;
constexpr UINT kPreviewStopMessage = WM_APP + 1;
void RegisterPreviewWindowClass()
{
static bool registered = false;
if (registered)
return;
WNDCLASSA windowClass = {};
windowClass.lpfnWndProc = PreviewWindowThread::WindowProc;
windowClass.hInstance = GetModuleHandleA(nullptr);
windowClass.lpszClassName = kPreviewWindowClassName;
windowClass.hCursor = LoadCursor(nullptr, IDC_ARROW);
windowClass.hbrBackground = reinterpret_cast<HBRUSH>(COLOR_WINDOW + 1);
RegisterClassA(&windowClass);
registered = true;
}
}
PreviewWindowThread::~PreviewWindowThread()
{
Stop();
}
bool PreviewWindowThread::Start(SystemFrameExchange& exchange, const PreviewWindowConfig& config, std::string& error)
{
if (!config.enabled)
return true;
if (mThread.joinable())
{
error = "Preview window thread is already running.";
return false;
}
mExchange = &exchange;
mConfig = config;
mStopRequested.store(false, std::memory_order_release);
mThread = std::thread(&PreviewWindowThread::ThreadMain, this);
return true;
}
void PreviewWindowThread::Stop()
{
mStopRequested.store(true, std::memory_order_release);
const DWORD threadId = mThreadId.load(std::memory_order_acquire);
if (threadId != 0)
PostThreadMessageA(threadId, kPreviewStopMessage, 0, 0);
if (mThread.joinable())
mThread.join();
mExchange = nullptr;
mRunning.store(false, std::memory_order_release);
}
LRESULT CALLBACK PreviewWindowThread::WindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam)
{
PreviewWindowThread* owner = reinterpret_cast<PreviewWindowThread*>(GetWindowLongPtrA(window, GWLP_USERDATA));
if (message == WM_NCCREATE)
{
const CREATESTRUCTA* create = reinterpret_cast<const CREATESTRUCTA*>(lParam);
owner = reinterpret_cast<PreviewWindowThread*>(create->lpCreateParams);
SetWindowLongPtrA(window, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(owner));
}
switch (message)
{
case WM_PAINT:
if (owner != nullptr)
{
owner->Paint(window);
return 0;
}
break;
case WM_TIMER:
if (owner != nullptr && wParam == kPreviewTimerId)
{
InvalidateRect(window, nullptr, FALSE);
return 0;
}
break;
case WM_CLOSE:
if (owner != nullptr)
{
if (!owner->mStopRequested.load(std::memory_order_acquire))
TryLog(LogLevel::Log, "preview", "Preview window closed by user.");
owner->mStopRequested.store(true, std::memory_order_release);
}
DestroyWindow(window);
return 0;
case WM_DESTROY:
if (owner != nullptr)
owner->mWindow = nullptr;
PostQuitMessage(0);
return 0;
default:
break;
}
return DefWindowProcA(window, message, wParam, lParam);
}
void PreviewWindowThread::ThreadMain()
{
MSG message = {};
PeekMessageA(&message, nullptr, WM_USER, WM_USER, PM_NOREMOVE);
mThreadId.store(GetCurrentThreadId(), std::memory_order_release);
mRunning.store(true, std::memory_order_release);
std::string error;
if (!CreatePreviewWindow(error))
{
TryLog(LogLevel::Error, "preview", error.empty() ? "Preview window creation failed." : error);
mThreadId.store(0, std::memory_order_release);
mRunning.store(false, std::memory_order_release);
return;
}
if (!mStopRequested.load(std::memory_order_acquire))
{
if (SetTimer(mWindow, kPreviewTimerId, PreviewTimerIntervalMilliseconds(mConfig.fps), nullptr) == 0)
TryLog(LogLevel::Error, "preview", "Preview window timer could not be started.");
else
TryLog(LogLevel::Log, "preview", "Preview window started.");
}
while (!mStopRequested.load(std::memory_order_acquire))
{
const BOOL result = GetMessageA(&message, nullptr, 0, 0);
if (result <= 0)
break;
if (message.message == kPreviewStopMessage)
{
mStopRequested.store(true, std::memory_order_release);
break;
}
TranslateMessage(&message);
DispatchMessageA(&message);
}
if (mWindow != nullptr)
{
KillTimer(mWindow, kPreviewTimerId);
DestroyWindow(mWindow);
mWindow = nullptr;
}
mThreadId.store(0, std::memory_order_release);
mRunning.store(false, std::memory_order_release);
TryLog(LogLevel::Log, "preview", "Preview window thread stopped.");
}
bool PreviewWindowThread::CreatePreviewWindow(std::string& error)
{
RegisterPreviewWindowClass();
mWindow = CreateWindowExA(
0,
kPreviewWindowClassName,
mConfig.title.c_str(),
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT,
CW_USEDEFAULT,
960,
540,
nullptr,
nullptr,
GetModuleHandleA(nullptr),
this);
if (mWindow == nullptr)
{
error = "CreateWindowEx failed for preview window.";
return false;
}
ShowWindow(mWindow, SW_SHOW);
UpdateWindow(mWindow);
return true;
}
void PreviewWindowThread::Paint(HWND window)
{
PAINTSTRUCT paint = {};
HDC dc = BeginPaint(window, &paint);
RECT client = {};
GetClientRect(window, &client);
const int clientWidth = std::max(1L, client.right - client.left);
const int clientHeight = std::max(1L, client.bottom - client.top);
SystemFrame frame;
const bool frameAcquired = mExchange != nullptr && mExchange->TryAcquireLatestForPreview(frame);
const bool canPaintFrame =
frameAcquired &&
frame.bytes != nullptr &&
frame.pixelFormat == VideoIOPixelFormat::Bgra8 &&
frame.width > 0 &&
frame.height > 0;
if (canPaintFrame)
{
BITMAPINFO bitmapInfo = {};
bitmapInfo.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bitmapInfo.bmiHeader.biWidth = static_cast<LONG>(frame.width);
bitmapInfo.bmiHeader.biHeight = -static_cast<LONG>(frame.height);
bitmapInfo.bmiHeader.biPlanes = 1;
bitmapInfo.bmiHeader.biBitCount = 32;
bitmapInfo.bmiHeader.biCompression = BI_RGB;
StretchDIBits(
dc,
0,
0,
clientWidth,
clientHeight,
0,
0,
static_cast<int>(frame.width),
static_cast<int>(frame.height),
frame.bytes,
&bitmapInfo,
DIB_RGB_COLORS,
SRCCOPY);
}
else
{
FillRect(dc, &client, reinterpret_cast<HBRUSH>(GetStockObject(BLACK_BRUSH)));
}
if (frameAcquired)
mExchange->ReleasePreviewFrame(frame);
EndPaint(window, &paint);
}
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include "PreviewConfig.h"
#include "../frames/SystemFrameTypes.h"
#include <atomic>
#include <thread>
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
class SystemFrameExchange;
namespace RenderCadenceCompositor
{
class PreviewWindowThread
{
public:
PreviewWindowThread() = default;
PreviewWindowThread(const PreviewWindowThread&) = delete;
PreviewWindowThread& operator=(const PreviewWindowThread&) = delete;
~PreviewWindowThread();
bool Start(SystemFrameExchange& exchange, const PreviewWindowConfig& config, std::string& error);
void Stop();
bool Running() const { return mRunning.load(std::memory_order_acquire); }
static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam);
private:
void ThreadMain();
bool CreatePreviewWindow(std::string& error);
void Paint(HWND window);
SystemFrameExchange* mExchange = nullptr;
PreviewWindowConfig mConfig;
std::thread mThread;
std::atomic<bool> mStopRequested{ false };
std::atomic<bool> mRunning{ false };
std::atomic<DWORD> mThreadId{ 0 };
HWND mWindow = nullptr;
};
}