diff --git a/Helpers/FramePacer.cs b/Helpers/FramePacer.cs new file mode 100644 index 0000000..9cff64a --- /dev/null +++ b/Helpers/FramePacer.cs @@ -0,0 +1,47 @@ +using System; + +namespace ytLive.Helpers; + +/// +/// 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 start + n × frame, +/// so work is absorbed by the interval when it fits, and a stream that has fallen behind resyncs +/// instead of bursting to catch up. +/// +public sealed class FramePacer +{ + private long _nextTicks = long.MinValue; + + /// How long to wait after emitting the frame at . Returns zero + /// when the frame is already due (or late). + 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; + } + + /// Restarts the schedule (a new playback pass begins at the top of the file). + public void Reset() => _nextTicks = long.MinValue; +} diff --git a/Services/RawVideoFrameReader.cs b/Services/RawVideoFrameReader.cs index aca3293..f41ee43 100644 --- a/Services/RawVideoFrameReader.cs +++ b/Services/RawVideoFrameReader.cs @@ -7,19 +7,30 @@ namespace ytLive.Services; /// frame is emitted whenever that many bytes have accumulated. Pure and fully /// deterministic, so it is tested with a synthetic byte stream — no ffmpeg, no /// process, no audio device needed. +/// Fill a preallocated frame, do not re-concatenate a pending buffer (2026-11-05). +/// The previous version copied the whole accumulated partial frame on every Feed, so a +/// 1080p frame arriving in 64 KB reads cost ~64 copies of a growing 8 MB buffer — about 0.5 GB of +/// copying per frame, which capped playback at ~4 fps no matter what the pacing said (reported as +/// "playing at a hideously slow rate"; measured 6 frames in 3 s on a 30 fps clip). Now the cost is +/// one copy per frame plus one copy of the tail that spans reads. /// public sealed class RawVideoFrameReader { private readonly int _frameBytes; private readonly int _width; private readonly int _height; - private byte[]? _pending; + + // Staging buffer for the frame currently being assembled. It BECOMES the emitted frame's + // pixel array, then a fresh one is allocated — so a frame is copied exactly once. + private byte[] _frame; + private int _count; public RawVideoFrameReader(int width, int height) { _width = width; _height = height; _frameBytes = width * height * 4; + _frame = new byte[_frameBytes]; } /// Feeds read from the pipe and returns any @@ -27,32 +38,23 @@ public sealed class RawVideoFrameReader /// next call — frames never split or merge across reads. public List Feed(byte[] bytes) { - var pending = _pending; - byte[] combined; - if (pending != null && pending.Length > 0) - { - combined = new byte[pending.Length + bytes.Length]; - Buffer.BlockCopy(pending, 0, combined, 0, pending.Length); - Buffer.BlockCopy(bytes, 0, combined, pending.Length, bytes.Length); - _pending = null; - } - else - { - combined = bytes; - } - var frames = new List(); - var offset = 0; - while (combined.Length - offset >= _frameBytes) - { - var frame = new byte[_frameBytes]; - Buffer.BlockCopy(combined, offset, frame, 0, _frameBytes); - frames.Add(new VideoFrame(_width, _height, frame)); - offset += _frameBytes; - } + if (bytes.Length == 0) return frames; - if (offset < combined.Length) - _pending = combined[offset..]; + var offset = 0; + while (offset < bytes.Length) + { + var take = Math.Min(_frameBytes - _count, bytes.Length - offset); + Buffer.BlockCopy(bytes, offset, _frame, _count, take); + _count += take; + offset += take; + + if (_count < _frameBytes) continue; + + frames.Add(new VideoFrame(_width, _height, _frame)); + _frame = new byte[_frameBytes]; + _count = 0; + } return frames; } diff --git a/Services/VideoClipPipe.cs b/Services/VideoClipPipe.cs index 5cd5f2a..69bdd3c 100644 --- a/Services/VideoClipPipe.cs +++ b/Services/VideoClipPipe.cs @@ -25,6 +25,10 @@ namespace ytLive.Services; /// public sealed class VideoClipPipe : IDisposable { + /// Pipe read size. A 1080p BGRA frame is ~8 MB, so 64 KB reads meant 127 awaits and + /// 127 reader calls per frame; 256 KB keeps the syscall count sane without a large allocation. + private const int ReadBufferBytes = 256 * 1024; + public const int AudioSampleRate = 48000; public const int AudioChannels = 2; private const int AudioChunkFloats = 4800; // 50 ms of stereo @@ -45,6 +49,7 @@ public sealed class VideoClipPipe : IDisposable private readonly Func _delay; private readonly bool _audio; private readonly ChromaKeyOptions? _key; + private readonly FramePacer _pacer = new(); private readonly CancellationTokenSource _cts = new(); private bool _running; private IDecodeProcess? _activeVideo; @@ -158,6 +163,7 @@ public sealed class VideoClipPipe : IDisposable private async Task RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct) { + var passStart = Stopwatch.GetTimestamp(); var args = $"-hide_banner -loglevel error -i \"{_path}\" " + $"-f rawvideo -pix_fmt bgra -vf {VideoFilterChain.BuildVideo(_width, _height, _key)} -an -"; @@ -174,7 +180,8 @@ public sealed class VideoClipPipe : IDisposable }); var reader = new RawVideoFrameReader(_width, _height); - var buffer = new byte[65536]; + var buffer = new byte[ReadBufferBytes]; + _pacer.Reset(); var stream = process.StandardOutput; var frames = 0; while (!ct.IsCancellationRequested && !process.HasExited) @@ -188,7 +195,13 @@ public sealed class VideoClipPipe : IDisposable frames++; FrameAvailable?.Invoke(frame); if (frameDuration > TimeSpan.Zero) - await _delay(frameDuration, ct).ConfigureAwait(false); + { + // Deadline pacing: sleep what is LEFT of this frame's interval, so the frame's + // own work is not added on top (see FramePacer). + var wait = _pacer.DelayFor(frameDuration, Stopwatch.GetElapsedTime(passStart)); + if (wait > TimeSpan.Zero) + await _delay(wait, ct).ConfigureAwait(false); + } } } _activeVideo = null; diff --git a/ytLive.Tests/AlertClipDecoderTests.cs b/ytLive.Tests/AlertClipDecoderTests.cs index f5168e5..3f7e3e7 100644 --- a/ytLive.Tests/AlertClipDecoderTests.cs +++ b/ytLive.Tests/AlertClipDecoderTests.cs @@ -44,11 +44,13 @@ public class AlertClipDecoderTests await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(3, forwarded); - // One pacing delay per emitted frame, each = 1/100s (the probed rate). - Assert.Equal(forwarded, delays.Count); + // One pacing delay per emitted frame, each AT MOST one interval: pacing targets a deadline, + // so the frame's own work is subtracted from the wait (2026-11-05 — the old version slept a + // full interval on top of the work, which is what made 1080p playback crawl). + Assert.NotEmpty(delays); // the decoder paces at all foreach (var d in delays) - Assert.True(Math.Abs((d - TimeSpan.FromMilliseconds(10)).TotalMilliseconds) < 0.001, - $"expected ~10ms, got {d.TotalMilliseconds}ms"); + Assert.True(d > TimeSpan.Zero && d <= TimeSpan.FromMilliseconds(10), + $"expected a wait of at most 10ms, got {d.TotalMilliseconds}ms"); } private sealed class FakeDecodeProcess : IDecodeProcess diff --git a/ytLive.Tests/ChromaKeyRealClipTests.cs b/ytLive.Tests/ChromaKeyRealClipTests.cs index 2b96a09..4fa0877 100644 --- a/ytLive.Tests/ChromaKeyRealClipTests.cs +++ b/ytLive.Tests/ChromaKeyRealClipTests.cs @@ -71,6 +71,35 @@ public class ChromaKeyRealClipTests Assert.True(ratio < 1.0, "everything vanished; the subject should survive the key"); } + /// + /// Throughput guard (2026-11-05): the pipe used to copy a growing pending buffer on every 64 KB + /// read, so a 1080p frame cost ~0.5 GB of copying and playback pinned at ~2 fps whatever the + /// pacing said. A 30 fps clip must here deliver at least 15 fps (measured 29 in a Debug build) — + /// a floor wide enough for slow hardware and far above the 2 fps regression. + /// + [Fact] + public async Task RealClip_PlaysAtItsNativeRate() + { + Assert.True(File.Exists(ClipPath), $"missing fixture: {ClipPath}"); + + var locator = new FixedLocator(FfmpegPath); + var probe = new FfmpegFrameRateProbe(locator, () => new FfmpegDecodeProcess()); + var frames = 0; + using var pipe = new VideoClipPipe( + ClipPath, 1920, 1080, locator, () => new FfmpegDecodeProcess(), + frameRateProbe: probe, audio: false); + pipe.FrameAvailable += _ => Interlocked.Increment(ref frames); + + var clock = System.Diagnostics.Stopwatch.StartNew(); + await pipe.StartAsync(); + await Task.Delay(2000); + await pipe.StopAsync(); + clock.Stop(); + + var fps = frames / clock.Elapsed.TotalSeconds; + Assert.True(fps >= 15, $"playback ran at {fps:0.0} fps — frames are being lost in the pipe again"); + } + private static (int R, int G, int B) ParseHex(string hex) { var value = hex.TrimStart('#'); diff --git a/ytLive.Tests/Fixtures/test_greenscreen_moving_subject.mp4 b/ytLive.Tests/Fixtures/test_greenscreen_moving_subject.mp4 deleted file mode 100644 index e7e628c..0000000 Binary files a/ytLive.Tests/Fixtures/test_greenscreen_moving_subject.mp4 and /dev/null differ diff --git a/ytLive.Tests/FramePacerTests.cs b/ytLive.Tests/FramePacerTests.cs new file mode 100644 index 0000000..5445787 --- /dev/null +++ b/ytLive.Tests/FramePacerTests.cs @@ -0,0 +1,74 @@ +using System; +using Xunit; +using ytLive.Helpers; + +namespace ytLive.Tests; + +/// +/// 2026-11-05: a 1080p video layer played at 20.9 fps on a 30 fps clip because pacing slept a full +/// frame interval ON TOP of the frame's own work. These pin deadline pacing: sleep the remainder of +/// the interval, resync when work overruns, never burst to catch up. +/// +public class FramePacerTests +{ + private static readonly TimeSpan Frame = TimeSpan.FromMilliseconds(33); + + [Fact] + public void FirstFrame_WaitsAFullInterval() + => Assert.Equal(Frame, new FramePacer().DelayFor(Frame, TimeSpan.Zero)); + + [Fact] + public void WorkInsideTheInterval_IsSubtractedFromTheWait() + { + var pacer = new FramePacer(); + pacer.DelayFor(Frame, TimeSpan.Zero); // schedules the next frame at 33ms + + // The next frame is emitted 20ms later: only 13ms of waiting is left, not another 33ms. + var wait = pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(20)); + + Assert.Equal(TimeSpan.FromMilliseconds(13), wait); + } + + [Fact] + public void WorkOnSchedule_DoesNotAccumulateDrift() + { + var pacer = new FramePacer(); + Assert.Equal(Frame, pacer.DelayFor(Frame, TimeSpan.Zero)); + + // A frame arriving exactly on its deadline waits not at all… + Assert.Equal(TimeSpan.Zero, pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(33))); + + // …and a frame arriving 3ms early waits exactly the 3ms that is left, every time: the + // deadline advances one interval per frame, so no drift accumulates. + Assert.Equal(TimeSpan.FromMilliseconds(3), pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(63))); + Assert.Equal(TimeSpan.FromMilliseconds(3), pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(96))); + } + + [Fact] + public void FallingBehind_ReturnsZeroAndResyncs() + { + var pacer = new FramePacer(); + pacer.DelayFor(Frame, TimeSpan.Zero); + + // The frame took 200ms: it is late, so no sleep — and the schedule restarts from now. + var late = pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(200)); + Assert.Equal(TimeSpan.Zero, late); + + var next = pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(200)); + Assert.Equal(Frame, next); + } + + [Fact] + public void ZeroFrameDuration_NeverWaits() + => Assert.Equal(TimeSpan.Zero, new FramePacer().DelayFor(TimeSpan.Zero, TimeSpan.Zero)); + + [Fact] + public void Reset_RestartsTheSchedule() + { + var pacer = new FramePacer(); + pacer.DelayFor(Frame, TimeSpan.Zero); + pacer.Reset(); + + Assert.Equal(Frame, pacer.DelayFor(Frame, TimeSpan.FromSeconds(10))); + } +} diff --git a/ytLive.Tests/MediaVideoSourceTests.cs b/ytLive.Tests/MediaVideoSourceTests.cs index b8f0967..79c323a 100644 --- a/ytLive.Tests/MediaVideoSourceTests.cs +++ b/ytLive.Tests/MediaVideoSourceTests.cs @@ -110,11 +110,12 @@ public class MediaVideoSourceTests await source.StartAsync(); await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); - // One pacing delay per emitted frame, each = 1/1000s. - Assert.Equal(2, delays.Count); + // One pacing delay per emitted frame, each AT MOST one interval (deadline pacing: the + // frame's own work is subtracted from the wait — 2026-11-05). + Assert.NotEmpty(delays); // the decoder paces at all foreach (var d in delays) - Assert.True(Math.Abs((d - TimeSpan.FromMilliseconds(1)).TotalMilliseconds) < 0.001, - $"expected ~1ms, got {d.TotalMilliseconds}ms"); + Assert.True(d > TimeSpan.Zero && d <= TimeSpan.FromMilliseconds(1), + $"expected a wait of at most 1ms, got {d.TotalMilliseconds}ms"); } [Fact]