Reported as "the default video and the green screen video are playing at a hideously slow rate… I thought the playback timing issues were resolved?!" They were not: two independent faults, both in the shared clip pipe. 1. QUADRATIC COPY IN THE FRAME READER. `RawVideoFrameReader` re-concatenated the whole pending buffer on every `Feed`, and the pipe feeds it 64 KB at a time. A 1080p BGRA frame is 8 MB, so one frame cost ~127 copies of a growing buffer — roughly half a gigabyte of memcpy per frame. Measured on a 30 fps clip: 6 frames in 3 seconds (2 fps). The reader now fills one preallocated frame buffer and hands that array to the VideoFrame, so a frame is copied exactly once. Pipe reads are 256 KB. Same clip: 29 fps. 2. PACING SLEPT ON TOP OF THE WORK. A fixed 1/fps sleep after each frame adds the frame's own cost, so 20 ms of work on a 30 fps clip yields 1/(33+20) ≈ 19 fps — measured 20.9. Pacing is now deadline-based (`Helpers/FramePacer.cs`): wait only what is left of the frame's interval, resync when a frame overruns instead of bursting, never sleep longer than one interval. The alert clip shares this pipe, so it gained both fixes. Tests: `FramePacerTests` (subtract work, no drift, late → resync, zero duration, reset); `ChromaKeyRealClipTests.RealClip_PlaysAtItsNativeRate` (throughput floor of 15 fps on the real 30 fps clip — the old code sat at 2); the two pacing assertions now check "at most one interval" rather than an exact sleep, which is what deadline pacing means. Full suite: 574 passed, 0 failed, clean build.
48 lines
2.0 KiB
C#
48 lines
2.0 KiB
C#
using System;
|
||
|
||
namespace ytLive.Helpers;
|
||
|
||
/// <summary>
|
||
/// Paces a frame stream to wall-clock deadlines instead of sleeping a fixed interval after each
|
||
/// frame. The difference matters: a fixed sleep ADDS the frame's own decode/copy cost, so a 30 fps
|
||
/// clip with 20 ms of work per frame plays at 1/(33+20) ≈ 19 fps — which is what a 1080p video layer
|
||
/// did (measured 20.9 fps on a 30 fps clip, 2026-11-05). Here the target is <c>start + n × frame</c>,
|
||
/// so work is absorbed by the interval when it fits, and a stream that has fallen behind resyncs
|
||
/// instead of bursting to catch up.
|
||
/// </summary>
|
||
public sealed class FramePacer
|
||
{
|
||
private long _nextTicks = long.MinValue;
|
||
|
||
/// <summary>How long to wait after emitting the frame at <paramref name="now"/>. Returns zero
|
||
/// when the frame is already due (or late).</summary>
|
||
public TimeSpan DelayFor(TimeSpan frameDuration, TimeSpan now)
|
||
{
|
||
if (frameDuration <= TimeSpan.Zero) return TimeSpan.Zero;
|
||
|
||
if (_nextTicks == long.MinValue)
|
||
{
|
||
_nextTicks = now.Ticks + frameDuration.Ticks;
|
||
return frameDuration;
|
||
}
|
||
|
||
var remaining = TimeSpan.FromTicks(_nextTicks - now.Ticks);
|
||
_nextTicks += frameDuration.Ticks;
|
||
|
||
if (remaining < TimeSpan.Zero)
|
||
{
|
||
// Late (the frame's work exceeded its interval): resync to now rather than emitting a
|
||
// burst of frames back-to-back, which the viewer would see as a speed-up.
|
||
_nextTicks = now.Ticks + frameDuration.Ticks;
|
||
return TimeSpan.Zero;
|
||
}
|
||
|
||
// Never wait longer than one interval, even if the caller's clock stood still (a fake delay
|
||
// in a test, or a suspended machine): a longer sleep would be a pause, not pacing.
|
||
return remaining > frameDuration ? frameDuration : remaining;
|
||
}
|
||
|
||
/// <summary>Restarts the schedule (a new playback pass begins at the top of the file).</summary>
|
||
public void Reset() => _nextTicks = long.MinValue;
|
||
}
|