From ac729dc2b96986a1e67202a6aae189d63c5963be Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Tue, 12 May 2026 00:52:33 +1000 Subject: [PATCH] Stage 1 rewrite --- CMakeLists.txt | 18 + .../presentation/RuntimeStatePresenter.cpp | 18 + .../runtime/telemetry/HealthTelemetry.cpp | 26 ++ .../runtime/telemetry/HealthTelemetry.h | 10 + .../videoio/RenderCadenceController.cpp | 102 +++++ .../videoio/RenderCadenceController.h | 68 +++ .../videoio/SystemOutputFramePool.cpp | 41 +- .../videoio/SystemOutputFramePool.h | 12 +- .../videoio/VideoBackend.cpp | 20 +- .../videoio/VideoBackend.h | 1 + .../videoio/VideoIOTypes.h | 4 + .../videoio/decklink/DeckLinkSession.cpp | 39 +- .../videoio/decklink/DeckLinkSession.h | 1 + docs/PHASE_7_5_READBACK_EXPERIMENT_LOG.md | 2 + ...7_6_SYSTEM_MEMORY_PLAYOUT_BUFFER_DESIGN.md | 5 + ...PHASE_7_7_RENDER_CADENCE_PLAYOUT_DESIGN.md | 430 ++++++++++++++++++ tests/HealthTelemetryTests.cpp | 24 + tests/RenderCadenceControllerTests.cpp | 172 +++++++ tests/SystemOutputFramePoolTests.cpp | 61 +++ tests/VideoPlayoutSchedulerTests.cpp | 18 +- 20 files changed, 1047 insertions(+), 25 deletions(-) create mode 100644 apps/LoopThroughWithOpenGLCompositing/videoio/RenderCadenceController.cpp create mode 100644 apps/LoopThroughWithOpenGLCompositing/videoio/RenderCadenceController.h create mode 100644 docs/PHASE_7_7_RENDER_CADENCE_PLAYOUT_DESIGN.md create mode 100644 tests/RenderCadenceControllerTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d73cb26..9655354 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -170,6 +170,8 @@ set(APP_SOURCES "${APP_DIR}/videoio/VideoIOTypes.h" "${APP_DIR}/videoio/OutputProductionController.cpp" "${APP_DIR}/videoio/OutputProductionController.h" + "${APP_DIR}/videoio/RenderCadenceController.cpp" + "${APP_DIR}/videoio/RenderCadenceController.h" "${APP_DIR}/videoio/RenderOutputQueue.cpp" "${APP_DIR}/videoio/RenderOutputQueue.h" "${APP_DIR}/videoio/SystemOutputFramePool.cpp" @@ -580,6 +582,22 @@ endif() add_test(NAME RenderOutputQueueTests COMMAND RenderOutputQueueTests) +add_executable(RenderCadenceControllerTests + "${APP_DIR}/videoio/RenderCadenceController.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceControllerTests.cpp" +) + +target_include_directories(RenderCadenceControllerTests PRIVATE + "${APP_DIR}" + "${APP_DIR}/videoio" +) + +if(MSVC) + target_compile_options(RenderCadenceControllerTests PRIVATE /W3) +endif() + +add_test(NAME RenderCadenceControllerTests COMMAND RenderCadenceControllerTests) + add_executable(SystemOutputFramePoolTests "${APP_DIR}/videoio/SystemOutputFramePool.cpp" "${APP_DIR}/videoio/VideoIOFormat.cpp" diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/presentation/RuntimeStatePresenter.cpp b/apps/LoopThroughWithOpenGLCompositing/runtime/presentation/RuntimeStatePresenter.cpp index 4a87a44..d0fe3c3 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/presentation/RuntimeStatePresenter.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/presentation/RuntimeStatePresenter.cpp @@ -51,6 +51,11 @@ JsonValue RuntimeStatePresenter::BuildRuntimeStateValue(const RuntimeStore& runt deckLink.set("externalKeyingRequested", JsonValue(telemetrySnapshot.videoIO.externalKeyingRequested)); deckLink.set("externalKeyingActive", JsonValue(telemetrySnapshot.videoIO.externalKeyingActive)); deckLink.set("statusMessage", JsonValue(telemetrySnapshot.videoIO.statusMessage)); + deckLink.set("actualBufferedFramesAvailable", JsonValue(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFramesAvailable)); + deckLink.set("actualBufferedFrames", JsonValue(static_cast(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFrames))); + deckLink.set("targetBufferedFrames", JsonValue(static_cast(telemetrySnapshot.backendPlayout.targetDeckLinkBufferedFrames))); + deckLink.set("scheduleCallMs", JsonValue(telemetrySnapshot.backendPlayout.deckLinkScheduleCallMilliseconds)); + deckLink.set("scheduleFailures", JsonValue(static_cast(telemetrySnapshot.backendPlayout.deckLinkScheduleFailureCount))); root.set("decklink", deckLink); JsonValue videoIO = JsonValue::MakeObject(); @@ -129,11 +134,22 @@ JsonValue RuntimeStatePresenter::BuildRuntimeStateValue(const RuntimeStore& runt recovery.set("completedFrameIndex", JsonValue(static_cast(telemetrySnapshot.backendPlayout.completedFrameIndex))); recovery.set("scheduledFrameIndex", JsonValue(static_cast(telemetrySnapshot.backendPlayout.scheduledFrameIndex))); recovery.set("scheduledLeadFrames", JsonValue(static_cast(telemetrySnapshot.backendPlayout.scheduledLeadFrames))); + recovery.set("syntheticScheduledLeadFrames", JsonValue(static_cast(telemetrySnapshot.backendPlayout.scheduledLeadFrames))); recovery.set("measuredLagFrames", JsonValue(static_cast(telemetrySnapshot.backendPlayout.measuredLagFrames))); recovery.set("catchUpFrames", JsonValue(static_cast(telemetrySnapshot.backendPlayout.catchUpFrames))); recovery.set("lateStreak", JsonValue(static_cast(telemetrySnapshot.backendPlayout.lateStreak))); recovery.set("dropStreak", JsonValue(static_cast(telemetrySnapshot.backendPlayout.dropStreak))); + JsonValue deckLinkPlayout = JsonValue::MakeObject(); + deckLinkPlayout.set("actualBufferedFramesAvailable", JsonValue(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFramesAvailable)); + deckLinkPlayout.set("actualBufferedFrames", JsonValue(static_cast(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFrames))); + deckLinkPlayout.set("targetBufferedFrames", JsonValue(static_cast(telemetrySnapshot.backendPlayout.targetDeckLinkBufferedFrames))); + deckLinkPlayout.set("scheduleCallMs", JsonValue(telemetrySnapshot.backendPlayout.deckLinkScheduleCallMilliseconds)); + deckLinkPlayout.set("scheduleFailures", JsonValue(static_cast(telemetrySnapshot.backendPlayout.deckLinkScheduleFailureCount))); + + JsonValue scheduler = JsonValue::MakeObject(); + scheduler.set("syntheticLeadFrames", JsonValue(static_cast(telemetrySnapshot.backendPlayout.scheduledLeadFrames))); + JsonValue backendPlayout = JsonValue::MakeObject(); backendPlayout.set("lifecycleState", JsonValue(telemetrySnapshot.backendPlayout.lifecycleState)); backendPlayout.set("degraded", JsonValue(telemetrySnapshot.backendPlayout.degraded)); @@ -144,6 +160,8 @@ JsonValue RuntimeStatePresenter::BuildRuntimeStateValue(const RuntimeStore& runt backendPlayout.set("readyQueue", readyQueue); backendPlayout.set("systemMemory", systemMemory); backendPlayout.set("outputRender", outputRender); + backendPlayout.set("decklink", deckLinkPlayout); + backendPlayout.set("scheduler", scheduler); backendPlayout.set("recovery", recovery); root.set("backendPlayout", backendPlayout); diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/telemetry/HealthTelemetry.cpp b/apps/LoopThroughWithOpenGLCompositing/runtime/telemetry/HealthTelemetry.cpp index df33aa5..f62b764 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/telemetry/HealthTelemetry.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/telemetry/HealthTelemetry.cpp @@ -347,6 +347,32 @@ bool HealthTelemetry::TryRecordSystemMemoryPlayoutStats(std::size_t freeFrameCou return true; } +void HealthTelemetry::RecordDeckLinkBufferTelemetry(bool actualBufferedFramesAvailable, uint64_t actualBufferedFrames, + std::size_t targetBufferedFrames, double scheduleCallMilliseconds, uint64_t scheduleFailureCount) +{ + std::lock_guard lock(mMutex); + mBackendPlayout.actualDeckLinkBufferedFramesAvailable = actualBufferedFramesAvailable; + mBackendPlayout.actualDeckLinkBufferedFrames = actualBufferedFramesAvailable ? actualBufferedFrames : 0; + mBackendPlayout.targetDeckLinkBufferedFrames = targetBufferedFrames; + mBackendPlayout.deckLinkScheduleCallMilliseconds = std::max(scheduleCallMilliseconds, 0.0); + mBackendPlayout.deckLinkScheduleFailureCount = scheduleFailureCount; +} + +bool HealthTelemetry::TryRecordDeckLinkBufferTelemetry(bool actualBufferedFramesAvailable, uint64_t actualBufferedFrames, + std::size_t targetBufferedFrames, double scheduleCallMilliseconds, uint64_t scheduleFailureCount) +{ + std::unique_lock lock(mMutex, std::try_to_lock); + if (!lock.owns_lock()) + return false; + + mBackendPlayout.actualDeckLinkBufferedFramesAvailable = actualBufferedFramesAvailable; + mBackendPlayout.actualDeckLinkBufferedFrames = actualBufferedFramesAvailable ? actualBufferedFrames : 0; + mBackendPlayout.targetDeckLinkBufferedFrames = targetBufferedFrames; + mBackendPlayout.deckLinkScheduleCallMilliseconds = std::max(scheduleCallMilliseconds, 0.0); + mBackendPlayout.deckLinkScheduleFailureCount = scheduleFailureCount; + return true; +} + void HealthTelemetry::RecordOutputRenderPipelineTiming( double drawMilliseconds, double fenceWaitMilliseconds, diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/telemetry/HealthTelemetry.h b/apps/LoopThroughWithOpenGLCompositing/runtime/telemetry/HealthTelemetry.h index 85a6f84..29be652 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/telemetry/HealthTelemetry.h +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/telemetry/HealthTelemetry.h @@ -126,6 +126,11 @@ public: uint64_t completedFrameIndex = 0; uint64_t scheduledFrameIndex = 0; uint64_t scheduledLeadFrames = 0; + bool actualDeckLinkBufferedFramesAvailable = false; + uint64_t actualDeckLinkBufferedFrames = 0; + std::size_t targetDeckLinkBufferedFrames = 0; + double deckLinkScheduleCallMilliseconds = 0.0; + uint64_t deckLinkScheduleFailureCount = 0; uint64_t measuredLagFrames = 0; uint64_t catchUpFrames = 0; uint64_t lateStreak = 0; @@ -213,6 +218,11 @@ public: std::size_t scheduledFrameCount, uint64_t underrunCount, uint64_t repeatCount, uint64_t dropCount, double frameAgeAtScheduleMilliseconds, double frameAgeAtCompletionMilliseconds); + void RecordDeckLinkBufferTelemetry(bool actualBufferedFramesAvailable, uint64_t actualBufferedFrames, + std::size_t targetBufferedFrames, double scheduleCallMilliseconds, uint64_t scheduleFailureCount); + bool TryRecordDeckLinkBufferTelemetry(bool actualBufferedFramesAvailable, uint64_t actualBufferedFrames, + std::size_t targetBufferedFrames, double scheduleCallMilliseconds, uint64_t scheduleFailureCount); + void RecordOutputRenderPipelineTiming( double drawMilliseconds, double fenceWaitMilliseconds, diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/RenderCadenceController.cpp b/apps/LoopThroughWithOpenGLCompositing/videoio/RenderCadenceController.cpp new file mode 100644 index 0000000..7981a36 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/RenderCadenceController.cpp @@ -0,0 +1,102 @@ +#include "RenderCadenceController.h" + +#include +#include + +void RenderCadenceController::Configure(Duration targetFrameDuration, TimePoint firstRenderTime, const RenderCadencePolicy& policy) +{ + mTargetFrameDuration = IsPositive(targetFrameDuration) ? targetFrameDuration : std::chrono::milliseconds(1); + mPolicy = policy; + if (mPolicy.skipThresholdFrames < 1.0) + mPolicy.skipThresholdFrames = 1.0; + Reset(firstRenderTime); +} + +void RenderCadenceController::Reset(TimePoint firstRenderTime) +{ + mNextRenderTime = firstRenderTime; + mNextFrameIndex = 0; + mMetrics = RenderCadenceMetrics(); +} + +RenderCadenceDecision RenderCadenceController::Tick(TimePoint now) +{ + RenderCadenceDecision decision; + decision.frameIndex = mNextFrameIndex; + decision.renderTargetTime = mNextRenderTime; + decision.nextRenderTime = mNextRenderTime; + + if (now < mNextRenderTime) + { + decision.action = RenderCadenceAction::Wait; + decision.waitDuration = mNextRenderTime - now; + decision.reason = "waiting-for-next-render-tick"; + return decision; + } + + const Duration lateness = now - mNextRenderTime; + const uint64_t skippedTicks = SkippedTicksForLateness(lateness); + if (skippedTicks > 0) + { + decision.skippedTicks = skippedTicks; + decision.frameIndex = mNextFrameIndex + skippedTicks; + decision.renderTargetTime = mNextRenderTime + (mTargetFrameDuration * skippedTicks); + decision.reason = "late-skip-render-ticks"; + mMetrics.skippedTickCount += skippedTicks; + } + else + { + decision.reason = IsPositive(lateness) ? "late-render-now" : "on-time-render"; + } + + decision.action = RenderCadenceAction::Render; + decision.lateness = now > decision.renderTargetTime + ? now - decision.renderTargetTime + : Duration::zero(); + mNextFrameIndex = decision.frameIndex + 1; + mNextRenderTime = decision.renderTargetTime + mTargetFrameDuration; + decision.nextRenderTime = mNextRenderTime; + + ++mMetrics.renderedFrameCount; + mMetrics.nextFrameIndex = mNextFrameIndex; + mMetrics.lastLateness = decision.lateness; + if (IsPositive(decision.lateness)) + { + ++mMetrics.lateFrameCount; + mMetrics.maxLateness = (std::max)(mMetrics.maxLateness, decision.lateness); + } + + return decision; +} + +uint64_t RenderCadenceController::SkippedTicksForLateness(Duration lateness) const +{ + if (!mPolicy.skipLateTicks || !IsPositive(lateness) || !IsPositive(mTargetFrameDuration)) + return 0; + + const double lateFrames = static_cast(lateness.count()) / static_cast(mTargetFrameDuration.count()); + if (lateFrames < mPolicy.skipThresholdFrames) + return 0; + + const uint64_t elapsedTicks = static_cast(std::floor(lateFrames)); + if (elapsedTicks == 0) + return 0; + return (std::min)(elapsedTicks, mPolicy.maxSkippedTicksPerDecision); +} + +bool RenderCadenceController::IsPositive(Duration duration) +{ + return duration > Duration::zero(); +} + +const char* RenderCadenceActionName(RenderCadenceAction action) +{ + switch (action) + { + case RenderCadenceAction::Render: + return "Render"; + case RenderCadenceAction::Wait: + default: + return "Wait"; + } +} diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/RenderCadenceController.h b/apps/LoopThroughWithOpenGLCompositing/videoio/RenderCadenceController.h new file mode 100644 index 0000000..8eba0ef --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/RenderCadenceController.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include + +enum class RenderCadenceAction +{ + Wait, + Render +}; + +struct RenderCadencePolicy +{ + bool skipLateTicks = true; + uint64_t maxSkippedTicksPerDecision = 4; + double skipThresholdFrames = 2.0; +}; + +struct RenderCadenceDecision +{ + RenderCadenceAction action = RenderCadenceAction::Wait; + uint64_t frameIndex = 0; + uint64_t skippedTicks = 0; + std::chrono::steady_clock::time_point renderTargetTime; + std::chrono::steady_clock::time_point nextRenderTime; + std::chrono::steady_clock::duration waitDuration = std::chrono::steady_clock::duration::zero(); + std::chrono::steady_clock::duration lateness = std::chrono::steady_clock::duration::zero(); + const char* reason = "waiting-for-next-render-tick"; +}; + +struct RenderCadenceMetrics +{ + uint64_t nextFrameIndex = 0; + uint64_t renderedFrameCount = 0; + uint64_t skippedTickCount = 0; + uint64_t lateFrameCount = 0; + std::chrono::steady_clock::duration lastLateness = std::chrono::steady_clock::duration::zero(); + std::chrono::steady_clock::duration maxLateness = std::chrono::steady_clock::duration::zero(); +}; + +class RenderCadenceController +{ +public: + using Clock = std::chrono::steady_clock; + using TimePoint = Clock::time_point; + using Duration = Clock::duration; + + void Configure(Duration targetFrameDuration, TimePoint firstRenderTime, const RenderCadencePolicy& policy = RenderCadencePolicy()); + void Reset(TimePoint firstRenderTime); + RenderCadenceDecision Tick(TimePoint now); + + Duration TargetFrameDuration() const { return mTargetFrameDuration; } + TimePoint NextRenderTime() const { return mNextRenderTime; } + uint64_t NextFrameIndex() const { return mNextFrameIndex; } + const RenderCadenceMetrics& Metrics() const { return mMetrics; } + +private: + uint64_t SkippedTicksForLateness(Duration lateness) const; + static bool IsPositive(Duration duration); + + Duration mTargetFrameDuration = std::chrono::milliseconds(16); + TimePoint mNextRenderTime; + uint64_t mNextFrameIndex = 0; + RenderCadencePolicy mPolicy; + RenderCadenceMetrics mMetrics; +}; + +const char* RenderCadenceActionName(RenderCadenceAction action); diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/SystemOutputFramePool.cpp b/apps/LoopThroughWithOpenGLCompositing/videoio/SystemOutputFramePool.cpp index 2d68c21..8318d28 100644 --- a/apps/LoopThroughWithOpenGLCompositing/videoio/SystemOutputFramePool.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/SystemOutputFramePool.cpp @@ -51,7 +51,7 @@ bool SystemOutputFramePool::AcquireFreeSlot(OutputFrameSlot& slot) if (mSlots[index].state != OutputFrameSlotState::Free) continue; - mSlots[index].state = OutputFrameSlotState::Acquired; + mSlots[index].state = OutputFrameSlotState::Rendering; ++mSlots[index].generation; FillOutputSlotLocked(index, slot); return true; @@ -62,16 +62,26 @@ bool SystemOutputFramePool::AcquireFreeSlot(OutputFrameSlot& slot) return false; } +bool SystemOutputFramePool::AcquireRenderingSlot(OutputFrameSlot& slot) +{ + return AcquireFreeSlot(slot); +} + bool SystemOutputFramePool::PublishReadySlot(const OutputFrameSlot& slot) { std::lock_guard lock(mMutex); - if (!TransitionSlotLocked(slot, OutputFrameSlotState::Acquired, OutputFrameSlotState::Ready)) + if (!TransitionSlotLocked(slot, OutputFrameSlotState::Rendering, OutputFrameSlotState::Completed)) return false; mReadySlots.push_back(slot.index); return true; } +bool SystemOutputFramePool::PublishCompletedSlot(const OutputFrameSlot& slot) +{ + return PublishReadySlot(slot); +} + bool SystemOutputFramePool::ConsumeReadySlot(OutputFrameSlot& slot) { std::lock_guard lock(mMutex); @@ -79,10 +89,9 @@ bool SystemOutputFramePool::ConsumeReadySlot(OutputFrameSlot& slot) { const std::size_t index = mReadySlots.front(); mReadySlots.pop_front(); - if (index >= mSlots.size() || mSlots[index].state != OutputFrameSlotState::Ready) + if (index >= mSlots.size() || mSlots[index].state != OutputFrameSlotState::Completed) continue; - mSlots[index].state = OutputFrameSlotState::Consumed; FillOutputSlotLocked(index, slot); return true; } @@ -92,16 +101,18 @@ bool SystemOutputFramePool::ConsumeReadySlot(OutputFrameSlot& slot) return false; } +bool SystemOutputFramePool::ConsumeCompletedSlot(OutputFrameSlot& slot) +{ + return ConsumeReadySlot(slot); +} + bool SystemOutputFramePool::MarkScheduled(const OutputFrameSlot& slot) { std::lock_guard lock(mMutex); if (!IsValidSlotLocked(slot)) return false; - if (mSlots[slot.index].state != OutputFrameSlotState::Ready && - mSlots[slot.index].state != OutputFrameSlotState::Consumed) - { + if (mSlots[slot.index].state != OutputFrameSlotState::Completed) return false; - } RemoveReadyIndexLocked(slot.index); mSlots[slot.index].state = OutputFrameSlotState::Scheduled; @@ -118,11 +129,8 @@ bool SystemOutputFramePool::MarkScheduledByBuffer(void* bytes) { if (mSlots[index].bytes.empty() || mSlots[index].bytes.data() != bytes) continue; - if (mSlots[index].state != OutputFrameSlotState::Ready && - mSlots[index].state != OutputFrameSlotState::Consumed) - { + if (mSlots[index].state != OutputFrameSlotState::Completed) return false; - } RemoveReadyIndexLocked(index); mSlots[index].state = OutputFrameSlotState::Scheduled; @@ -187,13 +195,12 @@ SystemOutputFramePoolMetrics SystemOutputFramePool::GetMetrics() const case OutputFrameSlotState::Free: ++metrics.freeCount; break; - case OutputFrameSlotState::Acquired: + case OutputFrameSlotState::Rendering: + ++metrics.renderingCount; ++metrics.acquiredCount; break; - case OutputFrameSlotState::Ready: - break; - case OutputFrameSlotState::Consumed: - ++metrics.consumedCount; + case OutputFrameSlotState::Completed: + ++metrics.completedCount; break; case OutputFrameSlotState::Scheduled: ++metrics.scheduledCount; diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/SystemOutputFramePool.h b/apps/LoopThroughWithOpenGLCompositing/videoio/SystemOutputFramePool.h index 3e836cd..f0ec666 100644 --- a/apps/LoopThroughWithOpenGLCompositing/videoio/SystemOutputFramePool.h +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/SystemOutputFramePool.h @@ -11,9 +11,8 @@ enum class OutputFrameSlotState { Free, - Acquired, - Ready, - Consumed, + Rendering, + Completed, Scheduled }; @@ -37,10 +36,12 @@ struct SystemOutputFramePoolMetrics { std::size_t capacity = 0; std::size_t freeCount = 0; + std::size_t renderingCount = 0; + std::size_t completedCount = 0; + std::size_t scheduledCount = 0; std::size_t acquiredCount = 0; std::size_t readyCount = 0; std::size_t consumedCount = 0; - std::size_t scheduledCount = 0; uint64_t acquireMissCount = 0; uint64_t readyUnderrunCount = 0; }; @@ -55,8 +56,11 @@ public: SystemOutputFramePoolConfig Config() const; bool AcquireFreeSlot(OutputFrameSlot& slot); + bool AcquireRenderingSlot(OutputFrameSlot& slot); bool PublishReadySlot(const OutputFrameSlot& slot); + bool PublishCompletedSlot(const OutputFrameSlot& slot); bool ConsumeReadySlot(OutputFrameSlot& slot); + bool ConsumeCompletedSlot(OutputFrameSlot& slot); bool MarkScheduled(const OutputFrameSlot& slot); bool MarkScheduledByBuffer(void* bytes); bool ReleaseSlot(const OutputFrameSlot& slot); diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.cpp b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.cpp index 2049981..196050c 100644 --- a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.cpp @@ -513,7 +513,6 @@ void VideoBackend::ProcessOutputFrameCompletion(const VideoIOCompletion& complet } NotifyOutputProducer(); - NotifyOutputProducer(); RecordBackendPlayoutHealth(completion.result, recoveryDecision); RecordSystemMemoryPlayoutStats(); } @@ -702,10 +701,12 @@ bool VideoBackend::ScheduleReadyOutputFrame() if (!ScheduleOutputFrame(readyFrame.frame)) { + RecordDeckLinkBufferTelemetry(); mSystemOutputFramePool.ReleaseSlotByBuffer(readyFrame.frame.bytes); return false; } + RecordDeckLinkBufferTelemetry(); PublishOutputFrameScheduled(readyFrame.frame); RecordSystemMemoryPlayoutStats(); return true; @@ -726,10 +727,12 @@ bool VideoBackend::ScheduleBlackUnderrunFrame() if (!ScheduleOutputFrame(outputFrame)) { + RecordDeckLinkBufferTelemetry(); ApplyLifecycleTransition(VideoBackendLifecycleState::Degraded, "Output underrun: black fallback frame scheduling failed."); return false; } + RecordDeckLinkBufferTelemetry(); ApplyLifecycleTransition(VideoBackendLifecycleState::Degraded, "Output underrun: scheduled black fallback frame."); PublishOutputFrameScheduled(outputFrame); return true; @@ -787,10 +790,25 @@ void VideoBackend::RecordReadyQueueDepthSample(const RenderOutputQueueMetrics& m ++mReadyQueueZeroDepthCount; } +void VideoBackend::RecordDeckLinkBufferTelemetry() +{ + if (!mVideoIODevice) + return; + + const VideoIOState& state = mVideoIODevice->State(); + mHealthTelemetry.TryRecordDeckLinkBufferTelemetry( + state.actualDeckLinkBufferedFramesAvailable, + state.actualDeckLinkBufferedFrames, + static_cast(mPlayoutPolicy.targetPrerollFrames), + state.deckLinkScheduleCallMilliseconds, + state.deckLinkScheduleFailureCount); +} + void VideoBackend::RecordSystemMemoryPlayoutStats() { const SystemOutputFramePoolMetrics poolMetrics = mSystemOutputFramePool.GetMetrics(); const RenderOutputQueueMetrics queueMetrics = mReadyOutputQueue.GetMetrics(); + RecordDeckLinkBufferTelemetry(); mHealthTelemetry.TryRecordSystemMemoryPlayoutStats( poolMetrics.freeCount, poolMetrics.readyCount, diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.h b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.h index 173e033..da7fdf7 100644 --- a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.h +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.h @@ -86,6 +86,7 @@ private: bool ScheduleBlackUnderrunFrame(); void RecordFramePacing(VideoIOCompletionResult completionResult); void RecordReadyQueueDepthSample(const RenderOutputQueueMetrics& metrics); + void RecordDeckLinkBufferTelemetry(); void RecordSystemMemoryPlayoutStats(); void RecordOutputRenderDuration(double renderMilliseconds, double acquireMilliseconds, double renderRequestMilliseconds, double endAccessMilliseconds); bool ApplyLifecycleTransition(VideoBackendLifecycleState state, const std::string& message); diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoIOTypes.h b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoIOTypes.h index bde3dc4..78acaf5 100644 --- a/apps/LoopThroughWithOpenGLCompositing/videoio/VideoIOTypes.h +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/VideoIOTypes.h @@ -50,6 +50,10 @@ struct VideoIOState bool keyerInterfaceAvailable = false; bool externalKeyingActive = false; double frameBudgetMilliseconds = 0.0; + bool actualDeckLinkBufferedFramesAvailable = false; + uint64_t actualDeckLinkBufferedFrames = 0; + double deckLinkScheduleCallMilliseconds = 0.0; + uint64_t deckLinkScheduleFailureCount = 0; }; struct VideoIOFrame diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/decklink/DeckLinkSession.cpp b/apps/LoopThroughWithOpenGLCompositing/videoio/decklink/DeckLinkSession.cpp index e31a774..a5f4904 100644 --- a/apps/LoopThroughWithOpenGLCompositing/videoio/decklink/DeckLinkSession.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/decklink/DeckLinkSession.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -526,8 +527,20 @@ bool DeckLinkSession::PopulateOutputFrame(IDeckLinkMutableVideoFrame* outputVide bool DeckLinkSession::ScheduleFrame(IDeckLinkMutableVideoFrame* outputVideoFrame) { const VideoIOScheduleTime scheduleTime = mScheduler.NextScheduleTime(); - return outputVideoFrame != nullptr && - output->ScheduleVideoFrame(outputVideoFrame, scheduleTime.streamTime, scheduleTime.duration, scheduleTime.timeScale) == S_OK; + if (outputVideoFrame == nullptr || output == nullptr) + { + ++mState.deckLinkScheduleFailureCount; + return false; + } + + const auto scheduleStart = std::chrono::steady_clock::now(); + const HRESULT result = output->ScheduleVideoFrame(outputVideoFrame, scheduleTime.streamTime, scheduleTime.duration, scheduleTime.timeScale); + const auto scheduleEnd = std::chrono::steady_clock::now(); + mState.deckLinkScheduleCallMilliseconds = std::chrono::duration_cast>(scheduleEnd - scheduleStart).count(); + if (result != S_OK) + ++mState.deckLinkScheduleFailureCount; + RefreshBufferedVideoFrameCount(); + return result == S_OK; } bool DeckLinkSession::ScheduleSystemMemoryFrame(const VideoIOOutputFrame& frame) @@ -592,6 +605,26 @@ bool DeckLinkSession::ScheduleBlackFrame(IDeckLinkMutableVideoFrame* outputVideo return ScheduleFrame(outputVideoFrame); } +void DeckLinkSession::RefreshBufferedVideoFrameCount() +{ + if (output == nullptr) + { + mState.actualDeckLinkBufferedFramesAvailable = false; + return; + } + + unsigned int bufferedFrameCount = 0; + if (output->GetBufferedVideoFrameCount(&bufferedFrameCount) == S_OK) + { + mState.actualDeckLinkBufferedFrames = bufferedFrameCount; + mState.actualDeckLinkBufferedFramesAvailable = true; + } + else + { + mState.actualDeckLinkBufferedFramesAvailable = false; + } +} + bool DeckLinkSession::BeginOutputFrame(VideoIOOutputFrame& frame) { CComPtr outputVideoFrame; @@ -736,6 +769,8 @@ void DeckLinkSession::HandleVideoInputFrame(IDeckLinkVideoInputFrame* inputFrame void DeckLinkSession::HandlePlayoutFrameCompleted(IDeckLinkVideoFrame* completedFrame, BMDOutputFrameCompletionResult completionResult) { + RefreshBufferedVideoFrameCount(); + void* completedSystemBuffer = nullptr; if (completedFrame != nullptr) { diff --git a/apps/LoopThroughWithOpenGLCompositing/videoio/decklink/DeckLinkSession.h b/apps/LoopThroughWithOpenGLCompositing/videoio/decklink/DeckLinkSession.h index 717f4c8..72a0182 100644 --- a/apps/LoopThroughWithOpenGLCompositing/videoio/decklink/DeckLinkSession.h +++ b/apps/LoopThroughWithOpenGLCompositing/videoio/decklink/DeckLinkSession.h @@ -74,6 +74,7 @@ private: bool ScheduleFrame(IDeckLinkMutableVideoFrame* outputVideoFrame); bool ScheduleSystemMemoryFrame(const VideoIOOutputFrame& frame); bool ScheduleBlackFrame(IDeckLinkMutableVideoFrame* outputVideoFrame); + void RefreshBufferedVideoFrameCount(); static VideoIOCompletionResult TranslateCompletionResult(BMDOutputFrameCompletionResult completionResult); CComPtr captureDelegate; diff --git a/docs/PHASE_7_5_READBACK_EXPERIMENT_LOG.md b/docs/PHASE_7_5_READBACK_EXPERIMENT_LOG.md index f67e093..7cf3cbc 100644 --- a/docs/PHASE_7_5_READBACK_EXPERIMENT_LOG.md +++ b/docs/PHASE_7_5_READBACK_EXPERIMENT_LOG.md @@ -2,6 +2,8 @@ This log tracks short readback experiments during the proactive playout timing work. +The later experiments point to a larger ownership change rather than more local fallback tweaks. The proposed follow-up design is [PHASE_7_7_RENDER_CADENCE_PLAYOUT_DESIGN.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/PHASE_7_7_RENDER_CADENCE_PLAYOUT_DESIGN.md). + ## How To Run The default debugger launch keeps the current production path: diff --git a/docs/PHASE_7_6_SYSTEM_MEMORY_PLAYOUT_BUFFER_DESIGN.md b/docs/PHASE_7_6_SYSTEM_MEMORY_PLAYOUT_BUFFER_DESIGN.md index aba8414..5fa0625 100644 --- a/docs/PHASE_7_6_SYSTEM_MEMORY_PLAYOUT_BUFFER_DESIGN.md +++ b/docs/PHASE_7_6_SYSTEM_MEMORY_PLAYOUT_BUFFER_DESIGN.md @@ -4,6 +4,11 @@ In progress. +Follow-up direction: + +- Phase 7.6 proved the BGRA8 system-memory path and exposed the need for a larger cadence/scheduler split. +- Continue the broader rewrite in [PHASE_7_7_RENDER_CADENCE_PLAYOUT_DESIGN.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/PHASE_7_7_RENDER_CADENCE_PLAYOUT_DESIGN.md). + Implemented so far: - BGRA8 `SystemOutputFramePool` with non-GL tests diff --git a/docs/PHASE_7_7_RENDER_CADENCE_PLAYOUT_DESIGN.md b/docs/PHASE_7_7_RENDER_CADENCE_PLAYOUT_DESIGN.md new file mode 100644 index 0000000..ac37b76 --- /dev/null +++ b/docs/PHASE_7_7_RENDER_CADENCE_PLAYOUT_DESIGN.md @@ -0,0 +1,430 @@ +# Phase 7.7: Render Cadence And Playout Separation Design + +## Status + +Proposed. + +Phase 7.5 and 7.6 proved useful pieces individually: + +- BGRA8 pack/readback can be fast enough on the current test machine. +- System-memory frame slots can be wrapped for DeckLink scheduling. +- A producer can keep frames ready and keep a small scheduled buffer filled. + +But the experiments also showed that the current hybrid ownership model is fragile: + +- completion-driven rendering caused app-ready starvation +- completion-time black fallback caused visible black flicker +- producer-side scheduling without a cadence target overfed the schedule timeline +- capping scheduled count helped, but completion and producer scheduling fought each other +- making completion passive exposed startup and scheduling-trigger gaps +- late/drop catch-up skipping created smooth/freeze/smooth cadence + +The lesson is that the app needs a larger architectural split, not more local recovery branches. + +## Goal + +Make the output path behave like two cooperating real-time systems: + +```text +Render cadence thread + renders at the selected output cadence, for example 59.94 fps + writes completed frames into system-memory slots + +DeckLink playout scheduler + keeps the device scheduled buffer topped up + consumes completed system-memory frames + never asks rendering to happen synchronously +``` + +The system-memory frame buffer becomes the contract between render timing and device timing. + +## Non-Goals + +- Do not hide failure by repeating frames as the primary strategy. +- Do not make DeckLink completion callbacks render frames. +- Do not use synthetic schedule-index catch-up as normal recovery. +- Do not change shader semantics or live-state semantics. +- Do not require v210/YUV packing in the first implementation. +- Do not pursue DVP/pinned-memory fast transfer as the main path on unsupported hardware. + +## Target Architecture + +### Current Problem Shape + +The current Phase 7.5/7.6 implementation still has too many timing authorities: + +- DeckLink completion callbacks release frames and influence scheduling +- the producer renders based on queue pressure +- the producer also schedules some frames +- `VideoPlayoutScheduler` advances synthetic stream-time indexes +- fallback behavior can schedule black when the app-ready queue is briefly empty + +That means the system can be full and still look wrong, because "full" is not tied to one clear cadence owner. + +### Target Shape + +```text +RenderCadenceController + owns output frame tick: frame 0, 1, 2... + owns render target time + asks RenderEngine to render frame N + publishes completed frame N into PlayoutFrameStore + +PlayoutFrameStore + owns free / rendering / completed / scheduled slots + tracks frame number, render time, completion time, and schedule state + exposes completed frames to DeckLink scheduler + +DeckLinkPlayoutScheduler + owns DeckLink schedule time + tops up device buffered frames to target depth + consumes completed frames only + releases scheduled slots on completion callbacks + +DeckLink completion callback + releases completed slots + records result and device timing + wakes scheduler + does not render +``` + +## Cadence Model + +The render side should be time-driven, not completion-driven. + +For a 59.94 fps mode: + +```text +frameDuration = 1001 / 60000 seconds +nextRenderTime = now + +loop: + wait until nextRenderTime, or run immediately if behind + render frameIndex for nextRenderTime + read back into free system-memory slot + publish completed slot + frameIndex += 1 + nextRenderTime += frameDuration +``` + +Rules: + +- If the render thread is early, it waits/yields. +- If it is slightly late, it renders the next frame immediately and records lateness. +- If it is badly late, policy may skip render ticks before rendering the newest frame. +- Skipping render ticks is a render-cadence decision, not a DeckLink stream-time jump. +- DeckLink schedule time should remain continuous unless a deliberate device recovery policy says otherwise. + +## Buffer Model + +Use a fixed system-memory slot pool. + +Suggested starting values: + +- completed-frame target: 2-4 frames +- DeckLink scheduled target: 4 frames for experiments +- total system slots: scheduled target + completed target + rendering spare + safety spare + +For example: + +```text +scheduled target: 4 +completed target: 3 +rendering/spare: 2 +total slots: 9 +``` + +Slot states: + +- `Free` +- `Rendering` +- `Completed` +- `Scheduled` + +Each slot should carry: + +- frame index +- render target timestamp +- render completion timestamp +- pixel format +- row bytes and size +- schedule timestamp/index when scheduled +- completion result when released + +## Scheduling Model + +The DeckLink scheduler should top up to a target device depth. + +```text +on scheduler wake: + while actualDeckLinkBufferedFrames < targetScheduledFrames: + frame = completedStore.popOldestCompleted() + if no frame: + record completed-frame underrun + break + schedule frame at next continuous DeckLink stream time +``` + +Important: + +- Use DeckLink `GetBufferedVideoFrameCount()` where available. +- Keep synthetic scheduled/completed indexes as diagnostics only. +- Do not infer device buffer depth from `mScheduledFrameIndex - mCompletedFrameIndex`. +- Do not schedule black because the app completed queue is momentarily empty while the device still has frames buffered. +- Use black only before the first valid frame or in explicit emergency fallback. + +## Thread Ownership + +### Render Cadence Thread + +Owns: + +- render tick timing +- acquiring a free system-memory slot +- requesting render-thread output render/readback +- publishing completed frames + +Does not own: + +- DeckLink schedule time +- completion callback processing +- fallback black scheduling + +### RenderEngine Render Thread + +Owns: + +- GL context +- input upload +- shader rendering +- output packing/readback +- preview present when allowed + +Output render work should have priority over preview/screenshot work. + +### DeckLink Scheduler Thread + +Owns: + +- schedule top-up policy +- DeckLink `ScheduleVideoFrame` +- device buffered-frame telemetry +- consuming completed frames + +Does not own: + +- rendering a missing frame +- running live-state composition directly + +### Completion Callback / Worker + +Owns: + +- releasing scheduled system slots +- recording completion result +- waking scheduler and render cadence loops + +Does not own: + +- rendering +- scheduling fallback black during normal steady state + +## What Happens Under Stress + +### Render Is Temporarily Late + +- Completed-frame queue drains. +- DeckLink scheduled buffer drains. +- Telemetry shows render lateness and completed queue depth drop. +- If render catches up before device buffer reaches zero, output remains smooth. + +### Render Cannot Sustain Cadence + +- Completed-frame queue stays low. +- DeckLink buffer trends down. +- Late/drop telemetry increases. +- Policy may choose to skip render ticks, lower preview load, or enter degraded state. + +### DeckLink Timing Jitters + +- Scheduler tops up based on actual device buffered count. +- Render cadence continues independently. +- System-memory buffer absorbs short mismatch. + +### UI Loses Focus + +- Render cadence should continue. +- Preview present may be disabled or deprioritized. +- Output/render threads may need elevated priority. +- Device buffer telemetry should reveal whether Windows focus changes affect render cadence or only preview. + +## Migration Plan + +### Step 1: Add Real DeckLink Buffer Telemetry + +Before more scheduling changes, measure the real device buffer. + +Deliverables: + +- call DeckLink `GetBufferedVideoFrameCount()` after schedule/completion where available +- expose `actualDeckLinkBufferedFrames` +- keep `scheduledLeadFrames` but label it synthetic/internal +- record schedule-call duration and failures + +Exit criteria: + +- runtime telemetry distinguishes app completed queue, system scheduled slots, synthetic lead, and actual DeckLink buffer depth + +### Step 2: Rename Existing Queues To Match Their Roles + +Clarify vocabulary before rewriting behavior. + +Deliverables: + +- rename or document `RenderOutputQueue` as completed/unscheduled frame queue +- distinguish completed-frame depth from device scheduled depth +- update telemetry labels where possible + +Exit criteria: + +- logs no longer imply `readyQueue.depth == 0` means DeckLink starvation + +### Step 3: Introduce `RenderCadenceController` + +Add a pure timing helper first. + +Responsibilities: + +- compute next render tick +- track frame duration +- report early/late/drift +- decide whether to render, wait, or skip render ticks + +Tests: + +- exact cadence advances +- late ticks are measured +- large lateness can skip according to policy +- no dependency on GL or DeckLink + +### Step 4: Move Output Production To Cadence Ticks + +Replace queue-pressure-only production with cadence-driven production. + +Initial behavior: + +- render at selected output cadence +- produce into system-memory slots +- publish completed frames +- pause when completed queue is at max depth + +Exit criteria: + +- output rendering continues without DeckLink completions +- output rendering does not schedule DeckLink directly + +### Step 5: Make DeckLink Scheduler A Separate Top-Up Loop + +Create a scheduler loop that consumes completed frames. + +Initial behavior: + +- wake on completion, completed-frame publish, and periodic safety timer +- top up actual DeckLink buffer to target +- schedule only completed system-memory frames +- do not render or black-fill during normal steady state + +Exit criteria: + +- producer and DeckLink scheduler are separate loops +- one component owns schedule time + +### Step 6: Remove Synthetic Catch-Up From Steady State + +Disable catch-up frame skipping for proactive mode. + +Replacement: + +- render cadence may skip render ticks if the renderer is late +- completed queue may drop oldest or newest according to explicit policy +- DeckLink schedule time remains continuous + +Exit criteria: + +- scheduled stream time advances one frame per scheduled frame unless emergency recovery is explicitly enabled + +### Step 7: Prioritize Output Render Work + +Reduce render-thread interference. + +Deliverables: + +- output render commands outrank preview present +- preview skipped/deferred count is visible +- input upload timing is measured separately +- screenshot/readback cannot block output cadence unless explicitly requested + +Exit criteria: + +- focus changes and preview present do not drain playout buffer + +### Step 8: Tune Thread Priority And Wait Strategy + +Only after ownership is separated, tune scheduling. + +Deliverables: + +- set render cadence and DeckLink scheduler threads to appropriate Windows priorities +- avoid busy spinning +- use waitable timers or high-resolution waits where useful +- record wake jitter + +Exit criteria: + +- cadence jitter is measurable and bounded + +## Telemetry + +Add or clarify: + +- `renderCadence.targetFps` +- `renderCadence.frameIndex` +- `renderCadence.lateMs` +- `renderCadence.maxLateMs` +- `renderCadence.skippedTicks` +- `completedFrames.depth` +- `completedFrames.capacity` +- `completedFrames.underruns` +- `systemMemory.free` +- `systemMemory.rendering` +- `systemMemory.completed` +- `systemMemory.scheduled` +- `decklink.actualBufferedFrames` +- `decklink.targetBufferedFrames` +- `decklink.scheduleCallMs` +- `decklink.scheduleFailures` +- `decklink.completionIntervalMs` +- `decklink.lateFrames` +- `decklink.droppedFrames` +- `scheduler.syntheticLeadFrames` + +## Risks + +- A cadence thread can render frames that DeckLink later drops if scheduling is wrong. +- Too much buffering adds latency. +- Too little buffering exposes Windows scheduling jitter. +- If output render and input upload still share one GL thread, render cadence can still be disturbed by uploads. +- Actual DeckLink buffer telemetry may differ from app-owned scheduled-slot counts. + +## Exit Criteria + +Phase 7.7 is complete when: + +- output rendering is driven by a render cadence controller +- DeckLink completion callbacks do not render +- DeckLink scheduling is owned by a scheduler/top-up loop +- system-memory completed frames are the only contract between render and DeckLink scheduling +- real DeckLink buffered-frame count is visible +- synthetic schedule lead no longer drives normal recovery +- black fallback is startup/emergency only +- playback can be tested with 4-frame and larger buffers without changing ownership logic diff --git a/tests/HealthTelemetryTests.cpp b/tests/HealthTelemetryTests.cpp index 629e3e8..974ba66 100644 --- a/tests/HealthTelemetryTests.cpp +++ b/tests/HealthTelemetryTests.cpp @@ -129,6 +129,7 @@ void TestBackendPlayoutHealth() Expect(playout.outputFrameEndAccessMilliseconds == 0.5, "backend playout health stores output frame end access duration"); Expect(playout.completedFrameIndex == 8, "backend playout health stores completed index"); Expect(playout.scheduledFrameIndex == 11, "backend playout health stores scheduled index"); + Expect(playout.scheduledLeadFrames == 3, "backend playout health stores synthetic scheduled lead"); Expect(playout.measuredLagFrames == 2, "backend playout health stores measured lag"); Expect(playout.catchUpFrames == 2, "backend playout health stores catch-up frames"); Expect(playout.lateStreak == 1, "backend playout health stores late streak"); @@ -228,6 +229,28 @@ void TestSystemMemoryPlayoutStats() Expect(playout.systemFrameAgeAtScheduleMilliseconds == 0.0, "system-memory playout clamps negative schedule age"); Expect(playout.systemFrameAgeAtCompletionMilliseconds == 0.0, "system-memory playout clamps negative completion age"); } + +void TestDeckLinkBufferTelemetry() +{ + HealthTelemetry telemetry; + telemetry.RecordDeckLinkBufferTelemetry(true, 4, 5, 0.25, 2); + + HealthTelemetry::BackendPlayoutSnapshot playout = telemetry.GetBackendPlayoutSnapshot(); + Expect(playout.actualDeckLinkBufferedFramesAvailable, "DeckLink buffer telemetry records availability"); + Expect(playout.actualDeckLinkBufferedFrames == 4, "DeckLink buffer telemetry stores actual device depth"); + Expect(playout.targetDeckLinkBufferedFrames == 5, "DeckLink buffer telemetry stores target device depth"); + Expect(playout.deckLinkScheduleCallMilliseconds == 0.25, "DeckLink buffer telemetry stores schedule call duration"); + Expect(playout.deckLinkScheduleFailureCount == 2, "DeckLink buffer telemetry stores schedule failures"); + + Expect(telemetry.TryRecordDeckLinkBufferTelemetry(false, 9, 3, -1.0, 7), + "try DeckLink buffer telemetry succeeds when uncontended"); + playout = telemetry.GetBackendPlayoutSnapshot(); + Expect(!playout.actualDeckLinkBufferedFramesAvailable, "DeckLink buffer telemetry records unavailable device depth"); + Expect(playout.actualDeckLinkBufferedFrames == 0, "unavailable DeckLink device depth clears actual count"); + Expect(playout.targetDeckLinkBufferedFrames == 3, "try DeckLink buffer telemetry stores target device depth"); + Expect(playout.deckLinkScheduleCallMilliseconds == 0.0, "DeckLink buffer telemetry clamps negative schedule call duration"); + Expect(playout.deckLinkScheduleFailureCount == 7, "try DeckLink buffer telemetry stores schedule failures"); +} } int main() @@ -239,6 +262,7 @@ int main() TestBackendPlayoutHealth(); TestOutputRenderPipelineTiming(); TestSystemMemoryPlayoutStats(); + TestDeckLinkBufferTelemetry(); if (gFailures != 0) { diff --git a/tests/RenderCadenceControllerTests.cpp b/tests/RenderCadenceControllerTests.cpp new file mode 100644 index 0000000..c619b23 --- /dev/null +++ b/tests/RenderCadenceControllerTests.cpp @@ -0,0 +1,172 @@ +#include "RenderCadenceController.h" + +#include +#include +#include + +namespace +{ +int gFailures = 0; + +using Clock = RenderCadenceController::Clock; +using Duration = RenderCadenceController::Duration; +using TimePoint = RenderCadenceController::TimePoint; + +void Expect(bool condition, const char* message) +{ + if (condition) + return; + + std::cerr << "FAIL: " << message << "\n"; + ++gFailures; +} + +Duration Ms(int64_t value) +{ + return std::chrono::duration_cast(std::chrono::milliseconds(value)); +} + +void TestExactCadenceAdvancesFrameIndexAndNextTick() +{ + RenderCadenceController controller; + const TimePoint start = Clock::time_point(Ms(1000)); + controller.Configure(Ms(20), start); + + RenderCadenceDecision first = controller.Tick(start); + Expect(first.action == RenderCadenceAction::Render, "first exact tick renders"); + Expect(first.frameIndex == 0, "first exact tick renders frame zero"); + Expect(first.renderTargetTime == start, "first exact target is configured start"); + Expect(first.nextRenderTime == start + Ms(20), "first exact tick advances next render time"); + Expect(first.skippedTicks == 0, "first exact tick skips no ticks"); + Expect(first.lateness == Duration::zero(), "first exact tick records no lateness"); + + RenderCadenceDecision second = controller.Tick(start + Ms(20)); + Expect(second.action == RenderCadenceAction::Render, "second exact tick renders"); + Expect(second.frameIndex == 1, "second exact tick renders frame one"); + Expect(controller.NextFrameIndex() == 2, "controller tracks next frame index after exact ticks"); + Expect(controller.Metrics().renderedFrameCount == 2, "metrics count exact rendered frames"); +} + +void TestEarlyTickWaitsWithoutAdvancing() +{ + RenderCadenceController controller; + const TimePoint start = Clock::time_point(Ms(0)); + controller.Configure(Ms(20), start); + (void)controller.Tick(start); + + RenderCadenceDecision decision = controller.Tick(start + Ms(10)); + Expect(decision.action == RenderCadenceAction::Wait, "early tick waits"); + Expect(decision.waitDuration == Ms(10), "early tick reports wait duration"); + Expect(decision.frameIndex == 1, "early tick reports next pending frame"); + Expect(controller.NextFrameIndex() == 1, "early tick does not advance frame index"); + Expect(controller.NextRenderTime() == start + Ms(20), "early tick does not advance next render time"); +} + +void TestSlightLatenessRendersAndRecordsMetrics() +{ + RenderCadencePolicy policy; + policy.skipThresholdFrames = 3.0; + + RenderCadenceController controller; + const TimePoint start = Clock::time_point(Ms(0)); + controller.Configure(Ms(20), start, policy); + + RenderCadenceDecision decision = controller.Tick(start + Ms(5)); + Expect(decision.action == RenderCadenceAction::Render, "slightly late tick renders"); + Expect(decision.frameIndex == 0, "slightly late tick keeps pending frame"); + Expect(decision.skippedTicks == 0, "slightly late tick skips no ticks"); + Expect(decision.lateness == Ms(5), "slightly late tick reports lateness"); + Expect(controller.Metrics().lateFrameCount == 1, "metrics count late rendered frame"); + Expect(controller.Metrics().lastLateness == Ms(5), "metrics keep last lateness"); + Expect(controller.Metrics().maxLateness == Ms(5), "metrics keep max lateness"); +} + +void TestLargeLatenessSkipsTicksAccordingToPolicy() +{ + RenderCadencePolicy policy; + policy.skipLateTicks = true; + policy.skipThresholdFrames = 2.0; + policy.maxSkippedTicksPerDecision = 8; + + RenderCadenceController controller; + const TimePoint start = Clock::time_point(Ms(0)); + controller.Configure(Ms(20), start, policy); + + RenderCadenceDecision decision = controller.Tick(start + Ms(70)); + Expect(decision.action == RenderCadenceAction::Render, "large late tick renders newest allowed frame"); + Expect(decision.skippedTicks == 3, "large late tick skips elapsed render ticks"); + Expect(decision.frameIndex == 3, "large late tick renders skipped-to frame"); + Expect(decision.renderTargetTime == start + Ms(60), "large late tick targets newest elapsed tick"); + Expect(decision.lateness == Ms(10), "large late tick measures residual lateness"); + Expect(controller.NextFrameIndex() == 4, "large late tick advances past rendered frame"); + Expect(controller.NextRenderTime() == start + Ms(80), "large late tick advances to following cadence"); + Expect(controller.Metrics().skippedTickCount == 3, "metrics count skipped ticks"); +} + +void TestSkipPolicyCanDisableOrCapSkippedTicks() +{ + const TimePoint start = Clock::time_point(Ms(0)); + + RenderCadencePolicy disabledPolicy; + disabledPolicy.skipLateTicks = false; + RenderCadenceController disabledController; + disabledController.Configure(Ms(20), start, disabledPolicy); + RenderCadenceDecision disabled = disabledController.Tick(start + Ms(90)); + Expect(disabled.skippedTicks == 0, "disabled skip policy renders pending frame"); + Expect(disabled.frameIndex == 0, "disabled skip policy preserves pending frame index"); + + RenderCadencePolicy cappedPolicy; + cappedPolicy.skipThresholdFrames = 1.0; + cappedPolicy.maxSkippedTicksPerDecision = 2; + RenderCadenceController cappedController; + cappedController.Configure(Ms(20), start, cappedPolicy); + RenderCadenceDecision capped = cappedController.Tick(start + Ms(90)); + Expect(capped.skippedTicks == 2, "skip policy caps skipped ticks"); + Expect(capped.frameIndex == 2, "capped skip renders capped frame index"); +} + +void TestResetRestartsCadenceAndMetrics() +{ + RenderCadenceController controller; + const TimePoint start = Clock::time_point(Ms(0)); + controller.Configure(Ms(20), start); + (void)controller.Tick(start + Ms(50)); + + const TimePoint restarted = start + Ms(200); + controller.Reset(restarted); + + Expect(controller.NextFrameIndex() == 0, "reset restarts frame index"); + Expect(controller.NextRenderTime() == restarted, "reset restarts next render time"); + Expect(controller.Metrics().renderedFrameCount == 0, "reset clears rendered metrics"); + + RenderCadenceDecision decision = controller.Tick(restarted); + Expect(decision.action == RenderCadenceAction::Render, "reset cadence renders at new start"); + Expect(decision.frameIndex == 0, "reset cadence renders frame zero"); +} + +void TestActionNames() +{ + Expect(RenderCadenceActionName(RenderCadenceAction::Render) == std::string("Render"), "render action has name"); + Expect(RenderCadenceActionName(RenderCadenceAction::Wait) == std::string("Wait"), "wait action has name"); +} +} + +int main() +{ + TestExactCadenceAdvancesFrameIndexAndNextTick(); + TestEarlyTickWaitsWithoutAdvancing(); + TestSlightLatenessRendersAndRecordsMetrics(); + TestLargeLatenessSkipsTicksAccordingToPolicy(); + TestSkipPolicyCanDisableOrCapSkippedTicks(); + TestResetRestartsCadenceAndMetrics(); + TestActionNames(); + + if (gFailures != 0) + { + std::cerr << gFailures << " RenderCadenceController test failure(s).\n"; + return 1; + } + + std::cout << "RenderCadenceController tests passed.\n"; + return 0; +} diff --git a/tests/SystemOutputFramePoolTests.cpp b/tests/SystemOutputFramePoolTests.cpp index 8cd3f2b..c649a46 100644 --- a/tests/SystemOutputFramePoolTests.cpp +++ b/tests/SystemOutputFramePoolTests.cpp @@ -48,10 +48,50 @@ void TestAcquireHonorsCapacityAndFrameShape() SystemOutputFramePoolMetrics metrics = pool.GetMetrics(); Expect(metrics.freeCount == 0, "all slots are in use"); + Expect(metrics.renderingCount == 2, "rendering slots are counted"); Expect(metrics.acquiredCount == 2, "acquired slots are counted"); Expect(metrics.acquireMissCount == 1, "capacity miss is counted"); } +void TestPhase77StateContract() +{ + SystemOutputFramePool pool(MakeConfig(1)); + + SystemOutputFramePoolMetrics metrics = pool.GetMetrics(); + Expect(metrics.freeCount == 1, "new pool starts with one free slot"); + Expect(metrics.renderingCount == 0, "new pool starts with no rendering slots"); + Expect(metrics.completedCount == 0, "new pool starts with no completed slots"); + Expect(metrics.scheduledCount == 0, "new pool starts with no scheduled slots"); + + OutputFrameSlot slot; + Expect(pool.AcquireRenderingSlot(slot), "free slot moves to rendering"); + metrics = pool.GetMetrics(); + Expect(metrics.freeCount == 0, "rendering slot leaves free pool"); + Expect(metrics.renderingCount == 1, "rendering slot is counted"); + + Expect(pool.PublishCompletedSlot(slot), "rendering slot moves to completed"); + metrics = pool.GetMetrics(); + Expect(metrics.renderingCount == 0, "completed slot leaves rendering"); + Expect(metrics.completedCount == 1, "completed slot is counted"); + Expect(metrics.readyCount == 1, "completed slot is available to scheduler"); + + OutputFrameSlot completed; + Expect(pool.ConsumeCompletedSlot(completed), "completed slot can be dequeued for scheduling"); + metrics = pool.GetMetrics(); + Expect(metrics.completedCount == 1, "dequeued completed slot remains completed until scheduled"); + Expect(metrics.readyCount == 0, "dequeued completed slot leaves ready queue"); + + Expect(pool.MarkScheduled(completed), "completed slot moves to scheduled"); + metrics = pool.GetMetrics(); + Expect(metrics.completedCount == 0, "scheduled slot leaves completed state"); + Expect(metrics.scheduledCount == 1, "scheduled slot is counted"); + + Expect(pool.ReleaseScheduledSlot(completed), "scheduled slot returns to free"); + metrics = pool.GetMetrics(); + Expect(metrics.freeCount == 1, "released scheduled slot returns to free"); + Expect(metrics.scheduledCount == 0, "released scheduled slot leaves scheduled state"); +} + void TestReadySlotsAreConsumedFifo() { SystemOutputFramePool pool(MakeConfig(2)); @@ -78,6 +118,25 @@ void TestReadySlotsAreConsumedFifo() Expect(metrics.readyCount == 0, "ready queue is empty after consumption"); } +void TestCompletedSlotCannotBeAcquiredUntilReleased() +{ + SystemOutputFramePool pool(MakeConfig(1)); + + OutputFrameSlot slot; + OutputFrameSlot extra; + Expect(pool.AcquireRenderingSlot(slot), "single slot can be acquired for rendering"); + Expect(pool.PublishCompletedSlot(slot), "single slot can be published completed"); + Expect(!pool.AcquireRenderingSlot(extra), "completed slot is not available for rendering"); + + OutputFrameSlot completed; + Expect(pool.ConsumeCompletedSlot(completed), "completed slot can be dequeued"); + Expect(!pool.AcquireRenderingSlot(extra), "dequeued completed slot is still not free"); + Expect(pool.MarkScheduled(completed), "dequeued completed slot can be scheduled"); + Expect(!pool.AcquireRenderingSlot(extra), "scheduled slot is still not free"); + Expect(pool.ReleaseScheduledSlot(completed), "scheduled slot can be released"); + Expect(pool.AcquireRenderingSlot(extra), "released slot can be acquired again"); +} + void TestReadySlotCanBeScheduledByBuffer() { SystemOutputFramePool pool(MakeConfig(1)); @@ -153,7 +212,9 @@ void TestEmptyReadyQueueUnderrunIsCounted() int main() { TestAcquireHonorsCapacityAndFrameShape(); + TestPhase77StateContract(); TestReadySlotsAreConsumedFifo(); + TestCompletedSlotCannotBeAcquiredUntilReleased(); TestReadySlotCanBeScheduledByBuffer(); TestInvalidTransitionsAreRejected(); TestPixelFormatAwareSizing(); diff --git a/tests/VideoPlayoutSchedulerTests.cpp b/tests/VideoPlayoutSchedulerTests.cpp index 231c0d4..cac491e 100644 --- a/tests/VideoPlayoutSchedulerTests.cpp +++ b/tests/VideoPlayoutSchedulerTests.cpp @@ -39,8 +39,11 @@ void TestScheduleAdvancesFromZero() void TestLateAndDroppedRecoveryUsesMeasuredPressure() { + VideoPlayoutPolicy policy; + policy.lateOrDropCatchUpFrames = 2; + VideoPlayoutScheduler scheduler; - scheduler.Configure(1000, 50000); + scheduler.Configure(1000, 50000, policy); (void)scheduler.NextScheduleTime(); VideoPlayoutRecoveryDecision lateDecision = scheduler.AccountForCompletionResult(VideoIOCompletionResult::DisplayedLate, 2); @@ -55,6 +58,18 @@ void TestLateAndDroppedRecoveryUsesMeasuredPressure() Expect(scheduler.NextScheduleTime().streamTime == 5000, "drop recovery advances by measured lag"); } +void TestDefaultPolicyReportsLagWithoutSkippingScheduleTime() +{ + VideoPlayoutScheduler scheduler; + scheduler.Configure(1000, 50000); + + (void)scheduler.NextScheduleTime(); + VideoPlayoutRecoveryDecision decision = scheduler.AccountForCompletionResult(VideoIOCompletionResult::Dropped, 0); + Expect(decision.measuredLagFrames > 0, "default policy still measures dropped-frame lag"); + Expect(decision.catchUpFrames == 0, "default policy does not skip schedule time"); + Expect(scheduler.NextScheduleTime().streamTime == 1000, "default recovery keeps stream time continuous"); +} + void TestMeasuredRecoveryIsCappedByPolicy() { VideoPlayoutPolicy policy; @@ -117,6 +132,7 @@ int main() { TestScheduleAdvancesFromZero(); TestLateAndDroppedRecoveryUsesMeasuredPressure(); + TestDefaultPolicyReportsLagWithoutSkippingScheduleTime(); TestMeasuredRecoveryIsCappedByPolicy(); TestCleanCompletionTracksCompletedIndexAndClearsStreaks(); TestPolicyNormalization();