2 Commits

Author SHA1 Message Date
Aiden
f1f4e3421b Frame timing
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m53s
CI / Windows Release Package (push) Successful in 3m6s
2026-05-12 01:08:32 +10:00
Aiden
ac729dc2b9 Stage 1 rewrite 2026-05-12 00:52:33 +10:00
23 changed files with 1167 additions and 43 deletions

View File

@@ -170,6 +170,8 @@ set(APP_SOURCES
"${APP_DIR}/videoio/VideoIOTypes.h" "${APP_DIR}/videoio/VideoIOTypes.h"
"${APP_DIR}/videoio/OutputProductionController.cpp" "${APP_DIR}/videoio/OutputProductionController.cpp"
"${APP_DIR}/videoio/OutputProductionController.h" "${APP_DIR}/videoio/OutputProductionController.h"
"${APP_DIR}/videoio/RenderCadenceController.cpp"
"${APP_DIR}/videoio/RenderCadenceController.h"
"${APP_DIR}/videoio/RenderOutputQueue.cpp" "${APP_DIR}/videoio/RenderOutputQueue.cpp"
"${APP_DIR}/videoio/RenderOutputQueue.h" "${APP_DIR}/videoio/RenderOutputQueue.h"
"${APP_DIR}/videoio/SystemOutputFramePool.cpp" "${APP_DIR}/videoio/SystemOutputFramePool.cpp"
@@ -580,6 +582,22 @@ endif()
add_test(NAME RenderOutputQueueTests COMMAND RenderOutputQueueTests) 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 add_executable(SystemOutputFramePoolTests
"${APP_DIR}/videoio/SystemOutputFramePool.cpp" "${APP_DIR}/videoio/SystemOutputFramePool.cpp"
"${APP_DIR}/videoio/VideoIOFormat.cpp" "${APP_DIR}/videoio/VideoIOFormat.cpp"

View File

@@ -51,6 +51,11 @@ JsonValue RuntimeStatePresenter::BuildRuntimeStateValue(const RuntimeStore& runt
deckLink.set("externalKeyingRequested", JsonValue(telemetrySnapshot.videoIO.externalKeyingRequested)); deckLink.set("externalKeyingRequested", JsonValue(telemetrySnapshot.videoIO.externalKeyingRequested));
deckLink.set("externalKeyingActive", JsonValue(telemetrySnapshot.videoIO.externalKeyingActive)); deckLink.set("externalKeyingActive", JsonValue(telemetrySnapshot.videoIO.externalKeyingActive));
deckLink.set("statusMessage", JsonValue(telemetrySnapshot.videoIO.statusMessage)); deckLink.set("statusMessage", JsonValue(telemetrySnapshot.videoIO.statusMessage));
deckLink.set("actualBufferedFramesAvailable", JsonValue(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFramesAvailable));
deckLink.set("actualBufferedFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFrames)));
deckLink.set("targetBufferedFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.targetDeckLinkBufferedFrames)));
deckLink.set("scheduleCallMs", JsonValue(telemetrySnapshot.backendPlayout.deckLinkScheduleCallMilliseconds));
deckLink.set("scheduleFailures", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.deckLinkScheduleFailureCount)));
root.set("decklink", deckLink); root.set("decklink", deckLink);
JsonValue videoIO = JsonValue::MakeObject(); JsonValue videoIO = JsonValue::MakeObject();
@@ -129,11 +134,22 @@ JsonValue RuntimeStatePresenter::BuildRuntimeStateValue(const RuntimeStore& runt
recovery.set("completedFrameIndex", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.completedFrameIndex))); recovery.set("completedFrameIndex", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.completedFrameIndex)));
recovery.set("scheduledFrameIndex", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.scheduledFrameIndex))); recovery.set("scheduledFrameIndex", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.scheduledFrameIndex)));
recovery.set("scheduledLeadFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.scheduledLeadFrames))); recovery.set("scheduledLeadFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.scheduledLeadFrames)));
recovery.set("syntheticScheduledLeadFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.scheduledLeadFrames)));
recovery.set("measuredLagFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.measuredLagFrames))); recovery.set("measuredLagFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.measuredLagFrames)));
recovery.set("catchUpFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.catchUpFrames))); recovery.set("catchUpFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.catchUpFrames)));
recovery.set("lateStreak", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.lateStreak))); recovery.set("lateStreak", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.lateStreak)));
recovery.set("dropStreak", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.dropStreak))); recovery.set("dropStreak", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.dropStreak)));
JsonValue deckLinkPlayout = JsonValue::MakeObject();
deckLinkPlayout.set("actualBufferedFramesAvailable", JsonValue(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFramesAvailable));
deckLinkPlayout.set("actualBufferedFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFrames)));
deckLinkPlayout.set("targetBufferedFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.targetDeckLinkBufferedFrames)));
deckLinkPlayout.set("scheduleCallMs", JsonValue(telemetrySnapshot.backendPlayout.deckLinkScheduleCallMilliseconds));
deckLinkPlayout.set("scheduleFailures", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.deckLinkScheduleFailureCount)));
JsonValue scheduler = JsonValue::MakeObject();
scheduler.set("syntheticLeadFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.scheduledLeadFrames)));
JsonValue backendPlayout = JsonValue::MakeObject(); JsonValue backendPlayout = JsonValue::MakeObject();
backendPlayout.set("lifecycleState", JsonValue(telemetrySnapshot.backendPlayout.lifecycleState)); backendPlayout.set("lifecycleState", JsonValue(telemetrySnapshot.backendPlayout.lifecycleState));
backendPlayout.set("degraded", JsonValue(telemetrySnapshot.backendPlayout.degraded)); backendPlayout.set("degraded", JsonValue(telemetrySnapshot.backendPlayout.degraded));
@@ -144,6 +160,8 @@ JsonValue RuntimeStatePresenter::BuildRuntimeStateValue(const RuntimeStore& runt
backendPlayout.set("readyQueue", readyQueue); backendPlayout.set("readyQueue", readyQueue);
backendPlayout.set("systemMemory", systemMemory); backendPlayout.set("systemMemory", systemMemory);
backendPlayout.set("outputRender", outputRender); backendPlayout.set("outputRender", outputRender);
backendPlayout.set("decklink", deckLinkPlayout);
backendPlayout.set("scheduler", scheduler);
backendPlayout.set("recovery", recovery); backendPlayout.set("recovery", recovery);
root.set("backendPlayout", backendPlayout); root.set("backendPlayout", backendPlayout);

View File

@@ -347,6 +347,32 @@ bool HealthTelemetry::TryRecordSystemMemoryPlayoutStats(std::size_t freeFrameCou
return true; return true;
} }
void HealthTelemetry::RecordDeckLinkBufferTelemetry(bool actualBufferedFramesAvailable, uint64_t actualBufferedFrames,
std::size_t targetBufferedFrames, double scheduleCallMilliseconds, uint64_t scheduleFailureCount)
{
std::lock_guard<std::mutex> 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<std::mutex> 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( void HealthTelemetry::RecordOutputRenderPipelineTiming(
double drawMilliseconds, double drawMilliseconds,
double fenceWaitMilliseconds, double fenceWaitMilliseconds,

View File

@@ -126,6 +126,11 @@ public:
uint64_t completedFrameIndex = 0; uint64_t completedFrameIndex = 0;
uint64_t scheduledFrameIndex = 0; uint64_t scheduledFrameIndex = 0;
uint64_t scheduledLeadFrames = 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 measuredLagFrames = 0;
uint64_t catchUpFrames = 0; uint64_t catchUpFrames = 0;
uint64_t lateStreak = 0; uint64_t lateStreak = 0;
@@ -213,6 +218,11 @@ public:
std::size_t scheduledFrameCount, uint64_t underrunCount, uint64_t repeatCount, uint64_t dropCount, std::size_t scheduledFrameCount, uint64_t underrunCount, uint64_t repeatCount, uint64_t dropCount,
double frameAgeAtScheduleMilliseconds, double frameAgeAtCompletionMilliseconds); 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( void RecordOutputRenderPipelineTiming(
double drawMilliseconds, double drawMilliseconds,
double fenceWaitMilliseconds, double fenceWaitMilliseconds,

View File

@@ -0,0 +1,102 @@
#include "RenderCadenceController.h"
#include <algorithm>
#include <cmath>
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<double>(lateness.count()) / static_cast<double>(mTargetFrameDuration.count());
if (lateFrames < mPolicy.skipThresholdFrames)
return 0;
const uint64_t elapsedTicks = static_cast<uint64_t>(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";
}
}

View File

@@ -0,0 +1,68 @@
#pragma once
#include <chrono>
#include <cstdint>
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);

View File

@@ -47,6 +47,18 @@ bool RenderOutputQueue::TryPop(RenderOutputFrame& frame)
return true; return true;
} }
bool RenderOutputQueue::DropOldestFrame()
{
std::lock_guard<std::mutex> lock(mMutex);
if (mReadyFrames.empty())
return false;
ReleaseFrame(mReadyFrames.front());
mReadyFrames.pop_front();
++mDroppedCount;
return true;
}
void RenderOutputQueue::Clear() void RenderOutputQueue::Clear()
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);

View File

@@ -34,6 +34,7 @@ public:
void Configure(const VideoPlayoutPolicy& policy); void Configure(const VideoPlayoutPolicy& policy);
bool Push(RenderOutputFrame frame); bool Push(RenderOutputFrame frame);
bool TryPop(RenderOutputFrame& frame); bool TryPop(RenderOutputFrame& frame);
bool DropOldestFrame();
void Clear(); void Clear();
RenderOutputQueueMetrics GetMetrics() const; RenderOutputQueueMetrics GetMetrics() const;

View File

@@ -51,7 +51,7 @@ bool SystemOutputFramePool::AcquireFreeSlot(OutputFrameSlot& slot)
if (mSlots[index].state != OutputFrameSlotState::Free) if (mSlots[index].state != OutputFrameSlotState::Free)
continue; continue;
mSlots[index].state = OutputFrameSlotState::Acquired; mSlots[index].state = OutputFrameSlotState::Rendering;
++mSlots[index].generation; ++mSlots[index].generation;
FillOutputSlotLocked(index, slot); FillOutputSlotLocked(index, slot);
return true; return true;
@@ -62,16 +62,26 @@ bool SystemOutputFramePool::AcquireFreeSlot(OutputFrameSlot& slot)
return false; return false;
} }
bool SystemOutputFramePool::AcquireRenderingSlot(OutputFrameSlot& slot)
{
return AcquireFreeSlot(slot);
}
bool SystemOutputFramePool::PublishReadySlot(const OutputFrameSlot& slot) bool SystemOutputFramePool::PublishReadySlot(const OutputFrameSlot& slot)
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);
if (!TransitionSlotLocked(slot, OutputFrameSlotState::Acquired, OutputFrameSlotState::Ready)) if (!TransitionSlotLocked(slot, OutputFrameSlotState::Rendering, OutputFrameSlotState::Completed))
return false; return false;
mReadySlots.push_back(slot.index); mReadySlots.push_back(slot.index);
return true; return true;
} }
bool SystemOutputFramePool::PublishCompletedSlot(const OutputFrameSlot& slot)
{
return PublishReadySlot(slot);
}
bool SystemOutputFramePool::ConsumeReadySlot(OutputFrameSlot& slot) bool SystemOutputFramePool::ConsumeReadySlot(OutputFrameSlot& slot)
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);
@@ -79,10 +89,9 @@ bool SystemOutputFramePool::ConsumeReadySlot(OutputFrameSlot& slot)
{ {
const std::size_t index = mReadySlots.front(); const std::size_t index = mReadySlots.front();
mReadySlots.pop_front(); mReadySlots.pop_front();
if (index >= mSlots.size() || mSlots[index].state != OutputFrameSlotState::Ready) if (index >= mSlots.size() || mSlots[index].state != OutputFrameSlotState::Completed)
continue; continue;
mSlots[index].state = OutputFrameSlotState::Consumed;
FillOutputSlotLocked(index, slot); FillOutputSlotLocked(index, slot);
return true; return true;
} }
@@ -92,16 +101,18 @@ bool SystemOutputFramePool::ConsumeReadySlot(OutputFrameSlot& slot)
return false; return false;
} }
bool SystemOutputFramePool::ConsumeCompletedSlot(OutputFrameSlot& slot)
{
return ConsumeReadySlot(slot);
}
bool SystemOutputFramePool::MarkScheduled(const OutputFrameSlot& slot) bool SystemOutputFramePool::MarkScheduled(const OutputFrameSlot& slot)
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);
if (!IsValidSlotLocked(slot)) if (!IsValidSlotLocked(slot))
return false; return false;
if (mSlots[slot.index].state != OutputFrameSlotState::Ready && if (mSlots[slot.index].state != OutputFrameSlotState::Completed)
mSlots[slot.index].state != OutputFrameSlotState::Consumed)
{
return false; return false;
}
RemoveReadyIndexLocked(slot.index); RemoveReadyIndexLocked(slot.index);
mSlots[slot.index].state = OutputFrameSlotState::Scheduled; 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) if (mSlots[index].bytes.empty() || mSlots[index].bytes.data() != bytes)
continue; continue;
if (mSlots[index].state != OutputFrameSlotState::Ready && if (mSlots[index].state != OutputFrameSlotState::Completed)
mSlots[index].state != OutputFrameSlotState::Consumed)
{
return false; return false;
}
RemoveReadyIndexLocked(index); RemoveReadyIndexLocked(index);
mSlots[index].state = OutputFrameSlotState::Scheduled; mSlots[index].state = OutputFrameSlotState::Scheduled;
@@ -187,13 +195,12 @@ SystemOutputFramePoolMetrics SystemOutputFramePool::GetMetrics() const
case OutputFrameSlotState::Free: case OutputFrameSlotState::Free:
++metrics.freeCount; ++metrics.freeCount;
break; break;
case OutputFrameSlotState::Acquired: case OutputFrameSlotState::Rendering:
++metrics.renderingCount;
++metrics.acquiredCount; ++metrics.acquiredCount;
break; break;
case OutputFrameSlotState::Ready: case OutputFrameSlotState::Completed:
break; ++metrics.completedCount;
case OutputFrameSlotState::Consumed:
++metrics.consumedCount;
break; break;
case OutputFrameSlotState::Scheduled: case OutputFrameSlotState::Scheduled:
++metrics.scheduledCount; ++metrics.scheduledCount;

View File

@@ -11,9 +11,8 @@
enum class OutputFrameSlotState enum class OutputFrameSlotState
{ {
Free, Free,
Acquired, Rendering,
Ready, Completed,
Consumed,
Scheduled Scheduled
}; };
@@ -37,10 +36,12 @@ struct SystemOutputFramePoolMetrics
{ {
std::size_t capacity = 0; std::size_t capacity = 0;
std::size_t freeCount = 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 acquiredCount = 0;
std::size_t readyCount = 0; std::size_t readyCount = 0;
std::size_t consumedCount = 0; std::size_t consumedCount = 0;
std::size_t scheduledCount = 0;
uint64_t acquireMissCount = 0; uint64_t acquireMissCount = 0;
uint64_t readyUnderrunCount = 0; uint64_t readyUnderrunCount = 0;
}; };
@@ -55,8 +56,11 @@ public:
SystemOutputFramePoolConfig Config() const; SystemOutputFramePoolConfig Config() const;
bool AcquireFreeSlot(OutputFrameSlot& slot); bool AcquireFreeSlot(OutputFrameSlot& slot);
bool AcquireRenderingSlot(OutputFrameSlot& slot);
bool PublishReadySlot(const OutputFrameSlot& slot); bool PublishReadySlot(const OutputFrameSlot& slot);
bool PublishCompletedSlot(const OutputFrameSlot& slot);
bool ConsumeReadySlot(OutputFrameSlot& slot); bool ConsumeReadySlot(OutputFrameSlot& slot);
bool ConsumeCompletedSlot(OutputFrameSlot& slot);
bool MarkScheduled(const OutputFrameSlot& slot); bool MarkScheduled(const OutputFrameSlot& slot);
bool MarkScheduledByBuffer(void* bytes); bool MarkScheduledByBuffer(void* bytes);
bool ReleaseSlot(const OutputFrameSlot& slot); bool ReleaseSlot(const OutputFrameSlot& slot);

View File

@@ -359,6 +359,12 @@ void VideoBackend::StartOutputProducerWorker()
if (mOutputProducerWorkerRunning) if (mOutputProducerWorkerRunning)
return; return;
const double frameBudgetMilliseconds = State().frameBudgetMilliseconds;
const auto frameDuration = frameBudgetMilliseconds > 0.0
? std::chrono::duration_cast<RenderCadenceController::Duration>(
std::chrono::duration<double, std::milli>(frameBudgetMilliseconds))
: std::chrono::milliseconds(16);
mRenderCadenceController.Configure(frameDuration, std::chrono::steady_clock::now());
mLastOutputProductionCompletion = VideoIOCompletion(); mLastOutputProductionCompletion = VideoIOCompletion();
mLastOutputProductionTime = std::chrono::steady_clock::time_point(); mLastOutputProductionTime = std::chrono::steady_clock::time_point();
mOutputProducerWorkerStopping = false; mOutputProducerWorkerStopping = false;
@@ -433,11 +439,16 @@ void VideoBackend::OutputProducerWorkerMain()
const RenderOutputQueueMetrics metrics = mReadyOutputQueue.GetMetrics(); const RenderOutputQueueMetrics metrics = mReadyOutputQueue.GetMetrics();
RecordReadyQueueDepthSample(metrics); RecordReadyQueueDepthSample(metrics);
const OutputProductionDecision decision = mOutputProductionController.Decide(BuildOutputProductionPressure(metrics));
if (decision.action != OutputProductionAction::Produce || decision.requestedFrames == 0) const auto now = std::chrono::steady_clock::now();
RenderCadenceDecision cadenceDecision = mRenderCadenceController.Tick(now);
if (cadenceDecision.action == RenderCadenceAction::Wait)
{ {
const auto waitDuration = (std::min)(
std::chrono::duration_cast<std::chrono::milliseconds>(cadenceDecision.waitDuration),
OutputProducerWakeInterval());
std::unique_lock<std::mutex> lock(mOutputProducerMutex); std::unique_lock<std::mutex> lock(mOutputProducerMutex);
mOutputProducerCondition.wait_for(lock, OutputProducerWakeInterval()); mOutputProducerCondition.wait_for(lock, waitDuration);
if (mOutputProducerWorkerStopping) if (mOutputProducerWorkerStopping)
{ {
mOutputProducerWorkerRunning = false; mOutputProducerWorkerRunning = false;
@@ -454,16 +465,7 @@ void VideoBackend::OutputProducerWorkerMain()
completion = mLastOutputProductionCompletion; completion = mLastOutputProductionCompletion;
} }
const bool belowTargetDepth = metrics.depth < decision.targetReadyFrames; const std::size_t producedFrames = ProduceReadyOutputFrames(completion, 1);
const auto now = std::chrono::steady_clock::now();
if (!belowTargetDepth &&
mLastOutputProductionTime != std::chrono::steady_clock::time_point() &&
now - mLastOutputProductionTime < OutputProducerWakeInterval())
{
continue;
}
const std::size_t producedFrames = ProduceReadyOutputFrames(completion, decision.requestedFrames);
if (producedFrames > 0) if (producedFrames > 0)
{ {
mLastOutputProductionTime = std::chrono::steady_clock::now(); mLastOutputProductionTime = std::chrono::steady_clock::now();
@@ -513,7 +515,6 @@ void VideoBackend::ProcessOutputFrameCompletion(const VideoIOCompletion& complet
} }
NotifyOutputProducer(); NotifyOutputProducer();
NotifyOutputProducer();
RecordBackendPlayoutHealth(completion.result, recoveryDecision); RecordBackendPlayoutHealth(completion.result, recoveryDecision);
RecordSystemMemoryPlayoutStats(); RecordSystemMemoryPlayoutStats();
} }
@@ -601,10 +602,6 @@ std::size_t VideoBackend::ProduceReadyOutputFrames(const VideoIOCompletion& comp
std::size_t producedFrames = 0; std::size_t producedFrames = 0;
while (producedFrames < maxFrames) while (producedFrames < maxFrames)
{ {
const OutputProductionDecision decision = mOutputProductionController.Decide(BuildOutputProductionPressure(metrics));
if (decision.action != OutputProductionAction::Produce)
break;
if (!RenderReadyOutputFrame(mVideoIODevice->State(), completion)) if (!RenderReadyOutputFrame(mVideoIODevice->State(), completion))
break; break;
++producedFrames; ++producedFrames;
@@ -635,7 +632,10 @@ bool VideoBackend::RenderReadyOutputFrame(const VideoIOState& state, const Video
VideoIOOutputFrame outputFrame; VideoIOOutputFrame outputFrame;
const auto acquireStart = std::chrono::steady_clock::now(); const auto acquireStart = std::chrono::steady_clock::now();
if (!mSystemOutputFramePool.AcquireFreeSlot(outputSlot)) if (!mSystemOutputFramePool.AcquireFreeSlot(outputSlot))
{
if (!mReadyOutputQueue.DropOldestFrame() || !mSystemOutputFramePool.AcquireFreeSlot(outputSlot))
return false; return false;
}
outputFrame = outputSlot.frame; outputFrame = outputSlot.frame;
const auto acquireEnd = std::chrono::steady_clock::now(); const auto acquireEnd = std::chrono::steady_clock::now();
@@ -702,10 +702,12 @@ bool VideoBackend::ScheduleReadyOutputFrame()
if (!ScheduleOutputFrame(readyFrame.frame)) if (!ScheduleOutputFrame(readyFrame.frame))
{ {
RecordDeckLinkBufferTelemetry();
mSystemOutputFramePool.ReleaseSlotByBuffer(readyFrame.frame.bytes); mSystemOutputFramePool.ReleaseSlotByBuffer(readyFrame.frame.bytes);
return false; return false;
} }
RecordDeckLinkBufferTelemetry();
PublishOutputFrameScheduled(readyFrame.frame); PublishOutputFrameScheduled(readyFrame.frame);
RecordSystemMemoryPlayoutStats(); RecordSystemMemoryPlayoutStats();
return true; return true;
@@ -726,10 +728,12 @@ bool VideoBackend::ScheduleBlackUnderrunFrame()
if (!ScheduleOutputFrame(outputFrame)) if (!ScheduleOutputFrame(outputFrame))
{ {
RecordDeckLinkBufferTelemetry();
ApplyLifecycleTransition(VideoBackendLifecycleState::Degraded, "Output underrun: black fallback frame scheduling failed."); ApplyLifecycleTransition(VideoBackendLifecycleState::Degraded, "Output underrun: black fallback frame scheduling failed.");
return false; return false;
} }
RecordDeckLinkBufferTelemetry();
ApplyLifecycleTransition(VideoBackendLifecycleState::Degraded, "Output underrun: scheduled black fallback frame."); ApplyLifecycleTransition(VideoBackendLifecycleState::Degraded, "Output underrun: scheduled black fallback frame.");
PublishOutputFrameScheduled(outputFrame); PublishOutputFrameScheduled(outputFrame);
return true; return true;
@@ -787,10 +791,25 @@ void VideoBackend::RecordReadyQueueDepthSample(const RenderOutputQueueMetrics& m
++mReadyQueueZeroDepthCount; ++mReadyQueueZeroDepthCount;
} }
void VideoBackend::RecordDeckLinkBufferTelemetry()
{
if (!mVideoIODevice)
return;
const VideoIOState& state = mVideoIODevice->State();
mHealthTelemetry.TryRecordDeckLinkBufferTelemetry(
state.actualDeckLinkBufferedFramesAvailable,
state.actualDeckLinkBufferedFrames,
static_cast<std::size_t>(mPlayoutPolicy.targetPrerollFrames),
state.deckLinkScheduleCallMilliseconds,
state.deckLinkScheduleFailureCount);
}
void VideoBackend::RecordSystemMemoryPlayoutStats() void VideoBackend::RecordSystemMemoryPlayoutStats()
{ {
const SystemOutputFramePoolMetrics poolMetrics = mSystemOutputFramePool.GetMetrics(); const SystemOutputFramePoolMetrics poolMetrics = mSystemOutputFramePool.GetMetrics();
const RenderOutputQueueMetrics queueMetrics = mReadyOutputQueue.GetMetrics(); const RenderOutputQueueMetrics queueMetrics = mReadyOutputQueue.GetMetrics();
RecordDeckLinkBufferTelemetry();
mHealthTelemetry.TryRecordSystemMemoryPlayoutStats( mHealthTelemetry.TryRecordSystemMemoryPlayoutStats(
poolMetrics.freeCount, poolMetrics.freeCount,
poolMetrics.readyCount, poolMetrics.readyCount,

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "OutputProductionController.h" #include "OutputProductionController.h"
#include "RenderCadenceController.h"
#include "RenderOutputQueue.h" #include "RenderOutputQueue.h"
#include "SystemOutputFramePool.h" #include "SystemOutputFramePool.h"
#include "VideoBackendLifecycle.h" #include "VideoBackendLifecycle.h"
@@ -86,6 +87,7 @@ private:
bool ScheduleBlackUnderrunFrame(); bool ScheduleBlackUnderrunFrame();
void RecordFramePacing(VideoIOCompletionResult completionResult); void RecordFramePacing(VideoIOCompletionResult completionResult);
void RecordReadyQueueDepthSample(const RenderOutputQueueMetrics& metrics); void RecordReadyQueueDepthSample(const RenderOutputQueueMetrics& metrics);
void RecordDeckLinkBufferTelemetry();
void RecordSystemMemoryPlayoutStats(); void RecordSystemMemoryPlayoutStats();
void RecordOutputRenderDuration(double renderMilliseconds, double acquireMilliseconds, double renderRequestMilliseconds, double endAccessMilliseconds); void RecordOutputRenderDuration(double renderMilliseconds, double acquireMilliseconds, double renderRequestMilliseconds, double endAccessMilliseconds);
bool ApplyLifecycleTransition(VideoBackendLifecycleState state, const std::string& message); bool ApplyLifecycleTransition(VideoBackendLifecycleState state, const std::string& message);
@@ -104,6 +106,7 @@ private:
VideoBackendLifecycle mLifecycle; VideoBackendLifecycle mLifecycle;
VideoPlayoutPolicy mPlayoutPolicy; VideoPlayoutPolicy mPlayoutPolicy;
OutputProductionController mOutputProductionController; OutputProductionController mOutputProductionController;
RenderCadenceController mRenderCadenceController;
RenderOutputQueue mReadyOutputQueue; RenderOutputQueue mReadyOutputQueue;
SystemOutputFramePool mSystemOutputFramePool; SystemOutputFramePool mSystemOutputFramePool;
std::unique_ptr<VideoIODevice> mVideoIODevice; std::unique_ptr<VideoIODevice> mVideoIODevice;

View File

@@ -50,6 +50,10 @@ struct VideoIOState
bool keyerInterfaceAvailable = false; bool keyerInterfaceAvailable = false;
bool externalKeyingActive = false; bool externalKeyingActive = false;
double frameBudgetMilliseconds = 0.0; double frameBudgetMilliseconds = 0.0;
bool actualDeckLinkBufferedFramesAvailable = false;
uint64_t actualDeckLinkBufferedFrames = 0;
double deckLinkScheduleCallMilliseconds = 0.0;
uint64_t deckLinkScheduleFailureCount = 0;
}; };
struct VideoIOFrame struct VideoIOFrame

View File

@@ -2,6 +2,7 @@
#include <atlbase.h> #include <atlbase.h>
#include <atomic> #include <atomic>
#include <chrono>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <new> #include <new>
@@ -526,8 +527,20 @@ bool DeckLinkSession::PopulateOutputFrame(IDeckLinkMutableVideoFrame* outputVide
bool DeckLinkSession::ScheduleFrame(IDeckLinkMutableVideoFrame* outputVideoFrame) bool DeckLinkSession::ScheduleFrame(IDeckLinkMutableVideoFrame* outputVideoFrame)
{ {
const VideoIOScheduleTime scheduleTime = mScheduler.NextScheduleTime(); const VideoIOScheduleTime scheduleTime = mScheduler.NextScheduleTime();
return outputVideoFrame != nullptr && if (outputVideoFrame == nullptr || output == nullptr)
output->ScheduleVideoFrame(outputVideoFrame, scheduleTime.streamTime, scheduleTime.duration, scheduleTime.timeScale) == S_OK; {
++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<std::chrono::duration<double, std::milli>>(scheduleEnd - scheduleStart).count();
if (result != S_OK)
++mState.deckLinkScheduleFailureCount;
RefreshBufferedVideoFrameCount();
return result == S_OK;
} }
bool DeckLinkSession::ScheduleSystemMemoryFrame(const VideoIOOutputFrame& frame) bool DeckLinkSession::ScheduleSystemMemoryFrame(const VideoIOOutputFrame& frame)
@@ -592,6 +605,26 @@ bool DeckLinkSession::ScheduleBlackFrame(IDeckLinkMutableVideoFrame* outputVideo
return ScheduleFrame(outputVideoFrame); 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) bool DeckLinkSession::BeginOutputFrame(VideoIOOutputFrame& frame)
{ {
CComPtr<IDeckLinkMutableVideoFrame> outputVideoFrame; CComPtr<IDeckLinkMutableVideoFrame> outputVideoFrame;
@@ -736,6 +769,8 @@ void DeckLinkSession::HandleVideoInputFrame(IDeckLinkVideoInputFrame* inputFrame
void DeckLinkSession::HandlePlayoutFrameCompleted(IDeckLinkVideoFrame* completedFrame, BMDOutputFrameCompletionResult completionResult) void DeckLinkSession::HandlePlayoutFrameCompleted(IDeckLinkVideoFrame* completedFrame, BMDOutputFrameCompletionResult completionResult)
{ {
RefreshBufferedVideoFrameCount();
void* completedSystemBuffer = nullptr; void* completedSystemBuffer = nullptr;
if (completedFrame != nullptr) if (completedFrame != nullptr)
{ {

View File

@@ -74,6 +74,7 @@ private:
bool ScheduleFrame(IDeckLinkMutableVideoFrame* outputVideoFrame); bool ScheduleFrame(IDeckLinkMutableVideoFrame* outputVideoFrame);
bool ScheduleSystemMemoryFrame(const VideoIOOutputFrame& frame); bool ScheduleSystemMemoryFrame(const VideoIOOutputFrame& frame);
bool ScheduleBlackFrame(IDeckLinkMutableVideoFrame* outputVideoFrame); bool ScheduleBlackFrame(IDeckLinkMutableVideoFrame* outputVideoFrame);
void RefreshBufferedVideoFrameCount();
static VideoIOCompletionResult TranslateCompletionResult(BMDOutputFrameCompletionResult completionResult); static VideoIOCompletionResult TranslateCompletionResult(BMDOutputFrameCompletionResult completionResult);
CComPtr<CaptureDelegate> captureDelegate; CComPtr<CaptureDelegate> captureDelegate;

View File

@@ -2,6 +2,8 @@
This log tracks short readback experiments during the proactive playout timing work. 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 ## How To Run
The default debugger launch keeps the current production path: The default debugger launch keeps the current production path:

View File

@@ -4,6 +4,11 @@
In progress. 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: Implemented so far:
- BGRA8 `SystemOutputFramePool` with non-GL tests - BGRA8 `SystemOutputFramePool` with non-GL tests

View File

@@ -0,0 +1,492 @@
# Phase 7.7: Render Cadence And Playout Separation Design
## Status
In progress.
Implemented so far:
- real DeckLink buffered-frame telemetry is exposed separately from synthetic scheduler lead
- pure `RenderCadenceController` exists with non-GL tests
- `SystemOutputFramePool` now exposes the Phase 7.7 state vocabulary: `Free`, `Rendering`, `Completed`, `Scheduled`
- the output producer now uses `RenderCadenceController` to render one output frame per cadence tick
- DeckLink scheduling remains a separate top-up pass capped by the configured preroll target
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.
Core principle:
- The render cadence should be stable and boring.
- If the selected output mode is 59.94 fps, the render producer should attempt to render at 59.94 fps.
- It should not speed up just because the DeckLink buffer is empty.
- It should not slow down because DeckLink is full or because completed frames have not drained.
- Completed-but-unscheduled frames are a latest-N cache. Old completed frames may be dropped/recycled to keep rendering at cadence.
- Scheduled frames are protected until DeckLink completes them.
- The only normal reason for the render cadence to deviate is that rendering/GPU work itself overruns the frame budget.
## 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
Startup / warmup
render cadence starts first
render thread produces warmup frames at the selected cadence
completed system-memory queue reaches warmup target
DeckLink preroll is scheduled from completed frames
DeckLink playback starts with a filled buffer
Steady state
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 latest completed frames to DeckLink scheduler
may drop/recycle oldest unscheduled completed frames when render cadence needs space
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 because render/GPU work overran the frame budget, policy may skip render ticks before rendering the newest frame.
- Skipping render ticks is an overrun policy, not a buffer-fill strategy.
- DeckLink schedule time should remain continuous unless a deliberate device recovery policy says otherwise.
Non-rule:
- The render producer must not render faster than the selected cadence to refill DeckLink.
- DeckLink should start only after warmup/preroll has filled enough completed frames.
- If the DeckLink buffer drains in steady state, that is a real timing failure to measure, not a signal for the render thread to sprint.
## Buffer Model
Use a fixed system-memory slot pool.
The completed portion of the pool is not a strict consume-before-render queue. It is a latest-N rendered-frame cache:
- render cadence writes one frame per selected output tick
- if completed-but-unscheduled frames are full, the oldest completed frame is disposable
- DeckLink scheduling consumes from the completed cache when it needs frames
- frames already scheduled to DeckLink are never recycled until completion
- if all slots are scheduled/in flight, cadence may miss because there is genuinely no safe system-memory target
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:
- [x] call DeckLink `GetBufferedVideoFrameCount()` after schedule/completion where available
- [x] expose `actualDeckLinkBufferedFrames`
- [x] keep `scheduledLeadFrames` but label it synthetic/internal
- [x] record schedule-call duration and failures
Exit criteria:
- [x] 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:
- [x] compute next render tick
- [x] track frame duration
- [x] report early/late/drift
- [x] decide whether to render, wait, or skip render ticks
Tests:
- [x] exact cadence advances
- [x] late ticks are measured
- [x] large lateness can skip according to policy
- [x] 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:
- [x] render at selected output cadence
- [x] produce into system-memory slots
- [x] publish completed frames
- [x] recycle/drop oldest unscheduled completed frames when cadence needs a slot
- [ ] only wait when every safe slot is scheduled/in flight
Exit criteria:
- output rendering continues without DeckLink completions
- output rendering does not schedule DeckLink directly
- completed-frame buffering behaves as latest-N, not consume-before-render
### Step 4a: Add Warmup Before DeckLink Playback
DeckLink output should not start consuming before the render cadence has prepared an initial cushion.
Initial behavior:
- configure DeckLink output without starting scheduled playback
- start the render cadence producer
- render warmup frames at the selected cadence, not faster
- wait until completed-frame depth reaches `targetWarmupFrames`
- schedule those completed frames as DeckLink preroll
- call `StartScheduledPlayback()`
Exit criteria:
- startup does not require the render producer to catch up by rendering faster than cadence
- DeckLink begins playback with a real completed-frame buffer
- if warmup cannot fill within a bounded timeout, startup enters degraded state with telemetry
### 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

View File

@@ -129,6 +129,7 @@ void TestBackendPlayoutHealth()
Expect(playout.outputFrameEndAccessMilliseconds == 0.5, "backend playout health stores output frame end access duration"); 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.completedFrameIndex == 8, "backend playout health stores completed index");
Expect(playout.scheduledFrameIndex == 11, "backend playout health stores scheduled 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.measuredLagFrames == 2, "backend playout health stores measured lag");
Expect(playout.catchUpFrames == 2, "backend playout health stores catch-up frames"); Expect(playout.catchUpFrames == 2, "backend playout health stores catch-up frames");
Expect(playout.lateStreak == 1, "backend playout health stores late streak"); 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.systemFrameAgeAtScheduleMilliseconds == 0.0, "system-memory playout clamps negative schedule age");
Expect(playout.systemFrameAgeAtCompletionMilliseconds == 0.0, "system-memory playout clamps negative completion 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() int main()
@@ -239,6 +262,7 @@ int main()
TestBackendPlayoutHealth(); TestBackendPlayoutHealth();
TestOutputRenderPipelineTiming(); TestOutputRenderPipelineTiming();
TestSystemMemoryPlayoutStats(); TestSystemMemoryPlayoutStats();
TestDeckLinkBufferTelemetry();
if (gFailures != 0) if (gFailures != 0)
{ {

View File

@@ -0,0 +1,172 @@
#include "RenderCadenceController.h"
#include <chrono>
#include <iostream>
#include <string>
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<Duration>(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;
}

View File

@@ -94,6 +94,29 @@ void TestOverflowReleasesDroppedFrame()
Expect(gReleasedFrames == 1, "pop transfers ownership without releasing"); Expect(gReleasedFrames == 1, "pop transfers ownership without releasing");
} }
void TestDropOldestFrameReleasesFrame()
{
gReleasedFrames = 0;
VideoPlayoutPolicy policy;
policy.maxReadyFrames = 2;
RenderOutputQueue queue(policy);
queue.Push(MakeOwnedFrame(1));
queue.Push(MakeOwnedFrame(2));
Expect(queue.DropOldestFrame(), "oldest ready frame can be explicitly dropped");
Expect(gReleasedFrames == 1, "explicit drop releases oldest frame");
RenderOutputQueueMetrics metrics = queue.GetMetrics();
Expect(metrics.depth == 1, "explicit drop reduces queue depth");
Expect(metrics.droppedCount == 1, "explicit drop increments dropped count");
RenderOutputFrame frame;
Expect(queue.TryPop(frame), "newest frame remains after explicit drop");
Expect(frame.frameIndex == 2, "explicit drop keeps newest frame");
Expect(!queue.DropOldestFrame(), "empty queue cannot drop a frame");
}
void TestUnderrunIsCounted() void TestUnderrunIsCounted()
{ {
RenderOutputQueue queue; RenderOutputQueue queue;
@@ -169,6 +192,7 @@ int main()
TestQueuePreservesOrdering(); TestQueuePreservesOrdering();
TestBoundedQueueDropsOldestFrame(); TestBoundedQueueDropsOldestFrame();
TestOverflowReleasesDroppedFrame(); TestOverflowReleasesDroppedFrame();
TestDropOldestFrameReleasesFrame();
TestUnderrunIsCounted(); TestUnderrunIsCounted();
TestConfigureShrinksDepthToNewCapacity(); TestConfigureShrinksDepthToNewCapacity();
TestConfigureReleasesTrimmedFrames(); TestConfigureReleasesTrimmedFrames();

View File

@@ -48,10 +48,50 @@ void TestAcquireHonorsCapacityAndFrameShape()
SystemOutputFramePoolMetrics metrics = pool.GetMetrics(); SystemOutputFramePoolMetrics metrics = pool.GetMetrics();
Expect(metrics.freeCount == 0, "all slots are in use"); 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.acquiredCount == 2, "acquired slots are counted");
Expect(metrics.acquireMissCount == 1, "capacity miss is 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() void TestReadySlotsAreConsumedFifo()
{ {
SystemOutputFramePool pool(MakeConfig(2)); SystemOutputFramePool pool(MakeConfig(2));
@@ -78,6 +118,25 @@ void TestReadySlotsAreConsumedFifo()
Expect(metrics.readyCount == 0, "ready queue is empty after consumption"); 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() void TestReadySlotCanBeScheduledByBuffer()
{ {
SystemOutputFramePool pool(MakeConfig(1)); SystemOutputFramePool pool(MakeConfig(1));
@@ -153,7 +212,9 @@ void TestEmptyReadyQueueUnderrunIsCounted()
int main() int main()
{ {
TestAcquireHonorsCapacityAndFrameShape(); TestAcquireHonorsCapacityAndFrameShape();
TestPhase77StateContract();
TestReadySlotsAreConsumedFifo(); TestReadySlotsAreConsumedFifo();
TestCompletedSlotCannotBeAcquiredUntilReleased();
TestReadySlotCanBeScheduledByBuffer(); TestReadySlotCanBeScheduledByBuffer();
TestInvalidTransitionsAreRejected(); TestInvalidTransitionsAreRejected();
TestPixelFormatAwareSizing(); TestPixelFormatAwareSizing();

View File

@@ -39,8 +39,11 @@ void TestScheduleAdvancesFromZero()
void TestLateAndDroppedRecoveryUsesMeasuredPressure() void TestLateAndDroppedRecoveryUsesMeasuredPressure()
{ {
VideoPlayoutPolicy policy;
policy.lateOrDropCatchUpFrames = 2;
VideoPlayoutScheduler scheduler; VideoPlayoutScheduler scheduler;
scheduler.Configure(1000, 50000); scheduler.Configure(1000, 50000, policy);
(void)scheduler.NextScheduleTime(); (void)scheduler.NextScheduleTime();
VideoPlayoutRecoveryDecision lateDecision = scheduler.AccountForCompletionResult(VideoIOCompletionResult::DisplayedLate, 2); VideoPlayoutRecoveryDecision lateDecision = scheduler.AccountForCompletionResult(VideoIOCompletionResult::DisplayedLate, 2);
@@ -55,6 +58,18 @@ void TestLateAndDroppedRecoveryUsesMeasuredPressure()
Expect(scheduler.NextScheduleTime().streamTime == 5000, "drop recovery advances by measured lag"); 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() void TestMeasuredRecoveryIsCappedByPolicy()
{ {
VideoPlayoutPolicy policy; VideoPlayoutPolicy policy;
@@ -117,6 +132,7 @@ int main()
{ {
TestScheduleAdvancesFromZero(); TestScheduleAdvancesFromZero();
TestLateAndDroppedRecoveryUsesMeasuredPressure(); TestLateAndDroppedRecoveryUsesMeasuredPressure();
TestDefaultPolicyReportsLagWithoutSkippingScheduleTime();
TestMeasuredRecoveryIsCappedByPolicy(); TestMeasuredRecoveryIsCappedByPolicy();
TestCleanCompletionTracksCompletedIndexAndClearsStreaks(); TestCleanCompletionTracksCompletedIndexAndClearsStreaks();
TestPolicyNormalization(); TestPolicyNormalization();