timing refactor
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 3m1s
CI / Windows Release Package (push) Successful in 3m20s

This commit is contained in:
Aiden
2026-05-12 23:39:57 +10:00
parent 4a049a557a
commit d411453f80
17 changed files with 125 additions and 112 deletions

View File

@@ -32,6 +32,17 @@ VideoIOScheduleTime VideoPlayoutScheduler::NextScheduleTime()
return time;
}
void VideoPlayoutScheduler::AlignNextScheduleTimeToPlayback(int64_t streamTime, uint64_t leadFrames)
{
if (mFrameDuration <= 0 || streamTime < 0)
return;
const uint64_t playbackFrameIndex = static_cast<uint64_t>(streamTime / mFrameDuration);
const uint64_t minimumScheduleIndex = playbackFrameIndex + leadFrames;
if (minimumScheduleIndex > mScheduledFrameIndex)
mScheduledFrameIndex = minimumScheduleIndex;
}
VideoPlayoutRecoveryDecision VideoPlayoutScheduler::AccountForCompletionResult(VideoIOCompletionResult result, uint64_t readyQueueDepth)
{
++mCompletedFrameIndex;

View File

@@ -12,6 +12,7 @@ public:
void Configure(int64_t frameDuration, int64_t timeScale, const VideoPlayoutPolicy& policy);
void Reset();
VideoIOScheduleTime NextScheduleTime();
void AlignNextScheduleTimeToPlayback(int64_t streamTime, uint64_t leadFrames);
VideoPlayoutRecoveryDecision AccountForCompletionResult(VideoIOCompletionResult result, uint64_t readyQueueDepth = 0);
double FrameBudgetMilliseconds() const;
uint64_t ScheduledFrameIndex() const { return mScheduledFrameIndex; }

View File

@@ -526,13 +526,24 @@ bool DeckLinkSession::PopulateOutputFrame(IDeckLinkMutableVideoFrame* outputVide
bool DeckLinkSession::ScheduleFrame(IDeckLinkMutableVideoFrame* outputVideoFrame)
{
const VideoIOScheduleTime scheduleTime = mScheduler.NextScheduleTime();
if (outputVideoFrame == nullptr || output == nullptr)
{
++mState.deckLinkScheduleFailureCount;
return false;
}
BMDTimeValue streamTime = 0;
double playbackSpeed = 0.0;
if (output->GetScheduledStreamTime(mScheduler.TimeScale(), &streamTime, &playbackSpeed) == S_OK && playbackSpeed > 0.0)
{
RefreshBufferedVideoFrameCount();
const uint64_t leadFrames = mState.actualDeckLinkBufferedFramesAvailable
? static_cast<uint64_t>(mState.actualDeckLinkBufferedFrames) + 1
: static_cast<uint64_t>(mPlayoutPolicy.targetPrerollFrames);
mScheduler.AlignNextScheduleTimeToPlayback(streamTime, leadFrames);
}
const VideoIOScheduleTime scheduleTime = mScheduler.NextScheduleTime();
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();

View File

@@ -11,14 +11,14 @@ Before adding features here, read the guardrails in [Render Cadence Golden Rules
```text
RenderThread
owns a hidden OpenGL context
polls latest input frames without waiting
polls the oldest ready input frame without waiting
uploads input frames into a render-owned GL texture
renders simple BGRA8 motion at selected cadence
queues async PBO readback
publishes completed frames into SystemFrameExchange
InputFrameMailbox
owns latest disposable CPU input slots
owns bounded FIFO CPU input slots
keeps a bounded three-ready-frame input buffer for render
trims frames beyond that bound to avoid runaway input latency
protects the one frame currently being uploaded by render
@@ -26,7 +26,7 @@ InputFrameMailbox
SystemFrameExchange
owns Free / Rendering / Completed / Scheduled slots
drops old completed unscheduled frames when render needs space
preserves completed output frames once they are waiting for playout
protects scheduled frames until DeckLink completion
DeckLinkOutputThread
@@ -286,8 +286,8 @@ Input telemetry:
- `renderFrameMaxMs`: maximum observed render-thread draw duration for this process
- `readbackQueueMs`: time spent queueing the most recent async BGRA8 PBO readback
- `completedReadbackCopyMs`: time spent mapping/copying the most recent completed readback into system-memory frame storage
- `completedDrops`: completed unscheduled system-memory frames dropped so render could reuse the slot
- `acquireMisses`: times render/readback could not acquire a writable system-memory frame slot
- `completedDrops`: completed unscheduled system-memory frames dropped by latest-N acquire paths; expected to stay flat in the cadence compositor output path
- `acquireMisses`: times render/readback could not acquire a writable system-memory frame slot; completed frames waiting for playout are preserved instead of being displaced
- `inputConsumeMisses`: render ticks where no ready input frame was available to upload
- `inputUploadMisses`: input texture upload attempts that reused the previous GL input texture
- `inputReadyFrames`: ready input frames currently queued in `InputFrameMailbox`

View File

@@ -111,14 +111,23 @@ int main(int argc, char** argv)
InputFrameMailbox inputMailbox(inputMailboxConfig);
VideoFormat inputVideoMode;
VideoFormat outputVideoMode;
std::string inputVideoModeError;
const bool inputVideoModeResolved = ResolveConfiguredVideoFormat(appConfig.inputVideoFormat, appConfig.inputFrameRate, inputVideoMode);
const bool outputVideoModeResolved = ResolveConfiguredVideoFormat(appConfig.outputVideoFormat, appConfig.outputFrameRate, outputVideoMode);
if (!inputVideoModeResolved)
{
inputVideoModeError = "Unsupported DeckLink inputVideoFormat/inputFrameRate in config/runtime-host.json: " +
appConfig.inputVideoFormat + " / " + appConfig.inputFrameRate;
RenderCadenceCompositor::LogWarning("app", inputVideoModeError);
}
if (!outputVideoModeResolved)
{
RenderCadenceCompositor::LogWarning(
"app",
"Unsupported DeckLink outputVideoFormat/outputFrameRate in config/runtime-host.json; render cadence will use parsed frame-rate fallback: " +
appConfig.outputVideoFormat + " / " + appConfig.outputFrameRate);
}
RenderCadenceCompositor::DeckLinkInput deckLinkInput(inputMailbox);
RenderCadenceCompositor::DeckLinkInputThread deckLinkInputThread(deckLinkInput);
@@ -173,7 +182,10 @@ int main(int argc, char** argv)
RenderThread::Config renderConfig;
renderConfig.width = frameExchangeConfig.width;
renderConfig.height = frameExchangeConfig.height;
renderConfig.frameDurationMilliseconds = RenderCadenceCompositor::FrameDurationMillisecondsFromRateString(appConfig.outputFrameRate);
const double fallbackFrameDurationMilliseconds = RenderCadenceCompositor::FrameDurationMillisecondsFromRateString(appConfig.outputFrameRate);
renderConfig.frameDurationMilliseconds = outputVideoModeResolved
? RenderCadenceCompositor::FrameDurationMillisecondsFromDisplayMode(outputVideoMode.displayMode, fallbackFrameDurationMilliseconds)
: fallbackFrameDurationMilliseconds;
renderConfig.pboDepth = 6;
RenderThread renderThread(frameExchange, &inputMailbox, renderConfig);

View File

@@ -4,6 +4,7 @@
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <sstream>
@@ -184,6 +185,50 @@ double FrameDurationMillisecondsFromRateString(const std::string& rateText, doub
return 1000.0 / rate;
}
double FrameDurationMillisecondsFromDisplayMode(BMDDisplayMode displayMode, double fallbackMilliseconds)
{
struct ModeRate
{
BMDDisplayMode mode;
int64_t frameDuration;
int64_t timeScale;
};
static const ModeRate rates[] =
{
{ bmdModeHD720p50, 1, 50 },
{ bmdModeHD720p5994, 1001, 60000 },
{ bmdModeHD720p60, 1, 60 },
{ bmdModeHD1080i50, 1, 25 },
{ bmdModeHD1080i5994, 1001, 30000 },
{ bmdModeHD1080i6000, 1, 30 },
{ bmdModeHD1080p2398, 1001, 24000 },
{ bmdModeHD1080p24, 1, 24 },
{ bmdModeHD1080p25, 1, 25 },
{ bmdModeHD1080p2997, 1001, 30000 },
{ bmdModeHD1080p30, 1, 30 },
{ bmdModeHD1080p50, 1, 50 },
{ bmdModeHD1080p5994, 1001, 60000 },
{ bmdModeHD1080p6000, 1, 60 },
{ bmdMode4K2160p2398, 1001, 24000 },
{ bmdMode4K2160p24, 1, 24 },
{ bmdMode4K2160p25, 1, 25 },
{ bmdMode4K2160p2997, 1001, 30000 },
{ bmdMode4K2160p30, 1, 30 },
{ bmdMode4K2160p50, 1, 50 },
{ bmdMode4K2160p5994, 1001, 60000 },
{ bmdMode4K2160p60, 1, 60 }
};
for (const ModeRate& rate : rates)
{
if (rate.mode == displayMode && rate.timeScale > 0)
return (static_cast<double>(rate.frameDuration) * 1000.0) / static_cast<double>(rate.timeScale);
}
return fallbackMilliseconds;
}
void VideoFormatDimensions(const std::string& formatName, unsigned& width, unsigned& height)
{
std::string normalized = formatName;

View File

@@ -1,6 +1,7 @@
#pragma once
#include "AppConfig.h"
#include "DeckLinkDisplayMode.h"
#include <filesystem>
#include <string>
@@ -27,6 +28,7 @@ private:
};
double FrameDurationMillisecondsFromRateString(const std::string& rateText, double fallbackRate = 59.94);
double FrameDurationMillisecondsFromDisplayMode(BMDDisplayMode displayMode, double fallbackMilliseconds);
void VideoFormatDimensions(const std::string& formatName, unsigned& width, unsigned& height);
std::filesystem::path FindConfigFile(const std::filesystem::path& relativePath = "config/runtime-host.json");
std::filesystem::path FindRepoPath(const std::filesystem::path& relativePath);

View File

@@ -120,38 +120,6 @@ bool InputFrameMailbox::SubmitFrame(const void* bytes, unsigned rowBytes, uint64
return true;
}
bool InputFrameMailbox::TryAcquireLatest(InputFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);
while (!mReadyIndices.empty())
{
const std::size_t index = mReadyIndices.back();
mReadyIndices.pop_back();
if (index >= mSlots.size() || mSlots[index].state != InputFrameSlotState::Ready)
continue;
while (!mReadyIndices.empty())
{
const std::size_t olderIndex = mReadyIndices.front();
mReadyIndices.pop_front();
if (olderIndex >= mSlots.size() || mSlots[olderIndex].state != InputFrameSlotState::Ready)
continue;
mSlots[olderIndex].state = InputFrameSlotState::Free;
++mSlots[olderIndex].generation;
++mCounters.droppedReadyFrames;
}
mSlots[index].state = InputFrameSlotState::Reading;
FillFrameLocked(index, frame);
++mCounters.consumedFrames;
return true;
}
frame = InputFrame();
++mCounters.consumeMisses;
return false;
}
bool InputFrameMailbox::TryAcquireOldest(InputFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);

View File

@@ -64,7 +64,6 @@ public:
InputFrameMailboxConfig Config() const;
bool SubmitFrame(const void* bytes, unsigned rowBytes, uint64_t frameIndex);
bool TryAcquireLatest(InputFrame& frame);
bool TryAcquireOldest(InputFrame& frame);
bool Release(const InputFrame& frame);
void Clear();

View File

@@ -46,14 +46,11 @@ bool SystemFrameExchange::AcquireForRender(SystemFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);
if (!AcquireFreeLocked(frame))
{
if (!DropOldestCompletedLocked() || !AcquireFreeLocked(frame))
{
frame = SystemFrame();
++mCounters.acquireMisses;
return false;
}
}
++mCounters.acquiredFrames;
return true;
@@ -234,27 +231,6 @@ bool SystemFrameExchange::AcquireFreeLocked(SystemFrame& frame)
return false;
}
bool SystemFrameExchange::DropOldestCompletedLocked()
{
while (!mCompletedIndices.empty())
{
const std::size_t index = mCompletedIndices.front();
mCompletedIndices.pop_front();
if (index >= mSlots.size() || mSlots[index].state != SystemFrameSlotState::Completed)
continue;
Slot& slot = mSlots[index];
slot.state = SystemFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
++mCounters.completedDrops;
mCondition.notify_all();
return true;
}
return false;
}
bool SystemFrameExchange::IsValidLocked(const SystemFrame& frame) const
{
return frame.index < mSlots.size() && mSlots[frame.index].generation == frame.generation;

View File

@@ -40,7 +40,6 @@ private:
};
bool AcquireFreeLocked(SystemFrame& frame);
bool DropOldestCompletedLocked();
bool IsValidLocked(const SystemFrame& frame) const;
void FillFrameLocked(std::size_t index, SystemFrame& frame);
std::size_t CompletedCountLocked() const;

View File

@@ -65,7 +65,7 @@ void RuntimeRenderScene::RenderFrame(uint64_t frameIndex, unsigned width, unsign
}
// Shader source contract:
// - gVideoInput is the decoded latest input texture for every layer in the stack.
// - gVideoInput is the decoded current input texture for every layer in the stack.
// - gLayerInput starts as gVideoInput for the first layer, then becomes the previous layer output.
GLuint layerInputTexture = videoInputTexture;
std::size_t nextTargetIndex = 0;

View File

@@ -82,7 +82,6 @@ private:
std::this_thread::sleep_for(mConfig.idleSleep);
continue;
}
SystemFrame frame;
if (!mExchange.ConsumeCompletedForSchedule(frame))
{

View File

@@ -107,6 +107,8 @@ void TestHelpers()
const double duration = FrameDurationMillisecondsFromRateString("50");
Expect(duration > 19.9 && duration < 20.1, "frame duration parses numeric rate");
const double deckLinkDuration = FrameDurationMillisecondsFromDisplayMode(bmdModeHD1080p5994, 0.0);
Expect(deckLinkDuration > 16.6833 && deckLinkDuration < 16.6834, "DeckLink 59.94 display mode duration is exact");
const std::filesystem::path configPath = FindConfigFile();
Expect(!configPath.empty(), "default config is discoverable from test working directory");

View File

@@ -57,32 +57,29 @@ void TestAcquirePublishesAndSchedules()
Expect(metrics.scheduledFrames == 1, "scheduled metric is counted");
}
void TestAcquireDropsOldestCompletedUnscheduled()
void TestAcquirePreservesCompletedFrames()
{
SystemFrameExchange exchange(MakeConfig(2));
SystemFrame first;
SystemFrame second;
SystemFrame third;
Expect(exchange.AcquireForRender(first), "first frame can be acquired");
Expect(exchange.AcquireForRender(first), "first preserving frame can be acquired");
first.frameIndex = 1;
Expect(exchange.PublishCompleted(first), "first frame can be completed");
Expect(exchange.AcquireForRender(second), "second frame can be acquired");
Expect(exchange.PublishCompleted(first), "first preserving frame can be completed");
Expect(exchange.AcquireForRender(second), "second preserving frame can be acquired");
second.frameIndex = 2;
Expect(exchange.PublishCompleted(second), "second frame can be completed");
Expect(exchange.PublishCompleted(second), "second preserving frame can be completed");
Expect(exchange.AcquireForRender(third), "third acquire drops the oldest completed frame");
Expect(third.index == first.index, "oldest completed slot is reused");
Expect(!exchange.AcquireForRender(third), "render acquire refuses to drop completed frames");
SystemFrame scheduled;
Expect(exchange.ConsumeCompletedForSchedule(scheduled), "remaining completed frame can be scheduled");
Expect(scheduled.index == second.index, "newer completed frame survives drop");
Expect(scheduled.frameIndex == 2, "newer frame index survives drop");
Expect(exchange.ConsumeCompletedForSchedule(scheduled), "oldest completed frame survives acquire miss");
Expect(scheduled.frameIndex == 1, "preserving acquire keeps FIFO output continuity");
SystemFrameExchangeMetrics metrics = exchange.Metrics();
Expect(metrics.completedDrops == 1, "drop metric is counted");
Expect(metrics.renderingCount == 1, "reused slot is rendering");
Expect(metrics.scheduledCount == 1, "consumed slot is scheduled");
Expect(metrics.completedDrops == 0, "preserving acquire does not count completed drops");
Expect(metrics.acquireMisses == 1, "preserving acquire miss is counted");
}
void TestScheduledFramesAreNotDropped()
@@ -179,7 +176,7 @@ void TestStableCompletedDepthTimesOut()
int main()
{
TestAcquirePublishesAndSchedules();
TestAcquireDropsOldestCompletedUnscheduled();
TestAcquirePreservesCompletedFrames();
TestScheduledFramesAreNotDropped();
TestGenerationValidationRejectsStaleFrames();
TestPixelFormatAwareSizing();

View File

@@ -41,28 +41,6 @@ std::vector<unsigned char> MakeFrame(unsigned char value)
return std::vector<unsigned char>(16, value);
}
void TestAcquireLatestDropsOlderReadyFrames()
{
InputFrameMailbox mailbox(MakeConfig(3));
const std::vector<unsigned char> frame1 = MakeFrame(1);
const std::vector<unsigned char> frame2 = MakeFrame(2);
const std::vector<unsigned char> frame3 = MakeFrame(3);
Expect(mailbox.SubmitFrame(frame1.data(), 8, 1), "first input frame submits");
Expect(mailbox.SubmitFrame(frame2.data(), 8, 2), "second input frame submits");
Expect(mailbox.SubmitFrame(frame3.data(), 8, 3), "third input frame submits");
InputFrame latest;
Expect(mailbox.TryAcquireLatest(latest), "latest input frame can be acquired");
Expect(latest.frameIndex == 3, "mailbox returns newest frame");
Expect(latest.bytes != nullptr && static_cast<const unsigned char*>(latest.bytes)[0] == 3, "latest frame bytes match newest frame");
Expect(mailbox.Release(latest), "latest input frame can be released");
const InputFrameMailboxMetrics metrics = mailbox.Metrics();
Expect(metrics.droppedReadyFrames == 2, "older ready input frames are dropped after latest acquire");
Expect(metrics.freeCount == 3, "all slots are free after release");
}
void TestSubmitDropsOldestWhenFull()
{
InputFrameMailbox mailbox(MakeConfig(2));
@@ -74,10 +52,10 @@ void TestSubmitDropsOldestWhenFull()
Expect(mailbox.SubmitFrame(frame2.data(), 8, 2), "second frame submits into full test");
Expect(mailbox.SubmitFrame(frame3.data(), 8, 3), "third frame submits by dropping oldest ready frame");
InputFrame latest;
Expect(mailbox.TryAcquireLatest(latest), "latest frame available after drop");
Expect(latest.frameIndex == 3, "newest frame survived full mailbox");
Expect(mailbox.Release(latest), "newest frame releases");
InputFrame oldest;
Expect(mailbox.TryAcquireOldest(oldest), "oldest frame available after drop");
Expect(oldest.frameIndex == 2, "bounded mailbox keeps FIFO order after trimming oldest overflow");
Expect(mailbox.Release(oldest), "oldest frame releases");
const InputFrameMailboxMetrics metrics = mailbox.Metrics();
Expect(metrics.droppedReadyFrames >= 1, "full mailbox records ready drop");
@@ -92,7 +70,7 @@ void TestReadingFrameIsProtected()
Expect(mailbox.SubmitFrame(frame1.data(), 8, 1), "protected frame submits");
InputFrame acquired;
Expect(mailbox.TryAcquireLatest(acquired), "protected frame acquired");
Expect(mailbox.TryAcquireOldest(acquired), "protected frame acquired");
Expect(!mailbox.SubmitFrame(frame2.data(), 8, 2), "producer cannot overwrite frame currently being read");
Expect(mailbox.Release(acquired), "protected frame releases");
Expect(mailbox.SubmitFrame(frame2.data(), 8, 2), "producer can submit after release");
@@ -143,7 +121,6 @@ void TestMaxReadyFramesKeepsConfiguredInputBuffer()
int main()
{
TestAcquireLatestDropsOlderReadyFrames();
TestSubmitDropsOldestWhenFull();
TestReadingFrameIsProtected();
TestAcquireOldestConsumesFifoWithoutDroppingReadyFrames();

View File

@@ -70,6 +70,19 @@ void TestDefaultPolicyReportsLagWithoutSkippingScheduleTime()
Expect(scheduler.NextScheduleTime().streamTime == 1000, "default recovery keeps stream time continuous");
}
void TestScheduleCursorCanAlignToPlaybackClock()
{
VideoPlayoutScheduler scheduler;
scheduler.Configure(1000, 50000);
(void)scheduler.NextScheduleTime();
scheduler.AlignNextScheduleTimeToPlayback(10000, 4);
Expect(scheduler.NextScheduleTime().streamTime == 14000, "schedule cursor skips stale stream time after underfeed");
scheduler.AlignNextScheduleTimeToPlayback(11000, 1);
Expect(scheduler.NextScheduleTime().streamTime == 15000, "schedule cursor does not move backward");
}
void TestMeasuredRecoveryIsCappedByPolicy()
{
VideoPlayoutPolicy policy;
@@ -133,6 +146,7 @@ int main()
TestScheduleAdvancesFromZero();
TestLateAndDroppedRecoveryUsesMeasuredPressure();
TestDefaultPolicyReportsLagWithoutSkippingScheduleTime();
TestScheduleCursorCanAlignToPlaybackClock();
TestMeasuredRecoveryIsCappedByPolicy();
TestCleanCompletionTracksCompletedIndexAndClearsStreaks();
TestPolicyNormalization();